@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/server/index.js
CHANGED
|
@@ -26935,6 +26935,30 @@ class ApiStore {
|
|
|
26935
26935
|
constructor(client) {
|
|
26936
26936
|
this.client = client;
|
|
26937
26937
|
}
|
|
26938
|
+
g(path, query) {
|
|
26939
|
+
return this.client.transport.get(path, query ? { query } : undefined);
|
|
26940
|
+
}
|
|
26941
|
+
post(path, body) {
|
|
26942
|
+
return this.client.transport.post(path, body);
|
|
26943
|
+
}
|
|
26944
|
+
patch(path, body) {
|
|
26945
|
+
return this.client.transport.patch(path, body);
|
|
26946
|
+
}
|
|
26947
|
+
del(path, query) {
|
|
26948
|
+
return this.client.transport.del(path, undefined, query ? { query } : undefined);
|
|
26949
|
+
}
|
|
26950
|
+
async gMaybe(path) {
|
|
26951
|
+
try {
|
|
26952
|
+
return await this.client.transport.get(path);
|
|
26953
|
+
} catch (e) {
|
|
26954
|
+
if (e && typeof e === "object" && e.status === 404)
|
|
26955
|
+
return null;
|
|
26956
|
+
throw e;
|
|
26957
|
+
}
|
|
26958
|
+
}
|
|
26959
|
+
enc(id) {
|
|
26960
|
+
return encodeURIComponent(String(id));
|
|
26961
|
+
}
|
|
26938
26962
|
async createContact(input) {
|
|
26939
26963
|
const res = await this.client.create("contacts", stripUndefined(input));
|
|
26940
26964
|
return pick2(res, "contact") ?? res;
|
|
@@ -27065,74 +27089,74 @@ class ApiStore {
|
|
|
27065
27089
|
async removeTagFromCompany() {
|
|
27066
27090
|
return unavailable("removeTagFromCompany");
|
|
27067
27091
|
}
|
|
27068
|
-
async createGroup() {
|
|
27069
|
-
return
|
|
27092
|
+
async createGroup(input) {
|
|
27093
|
+
return pick2(await this.post("/groups", input), "group");
|
|
27070
27094
|
}
|
|
27071
|
-
async getGroup() {
|
|
27072
|
-
return
|
|
27095
|
+
async getGroup(id) {
|
|
27096
|
+
return pick2(await this.gMaybe(`/groups/${this.enc(id)}`), "group") ?? null;
|
|
27073
27097
|
}
|
|
27074
|
-
async listGroups() {
|
|
27075
|
-
return
|
|
27098
|
+
async listGroups(projectId) {
|
|
27099
|
+
return pick2(await this.g("/groups", projectId ? { project_id: projectId } : undefined), "groups") ?? [];
|
|
27076
27100
|
}
|
|
27077
|
-
async updateGroup() {
|
|
27078
|
-
return
|
|
27101
|
+
async updateGroup(id, input) {
|
|
27102
|
+
return pick2(await this.patch(`/groups/${this.enc(id)}`, input), "group");
|
|
27079
27103
|
}
|
|
27080
|
-
async deleteGroup() {
|
|
27081
|
-
|
|
27104
|
+
async deleteGroup(id) {
|
|
27105
|
+
await this.del(`/groups/${this.enc(id)}`);
|
|
27082
27106
|
}
|
|
27083
|
-
async addContactToGroup() {
|
|
27084
|
-
return
|
|
27107
|
+
async addContactToGroup(contactId, groupId) {
|
|
27108
|
+
return this.post(`/groups/${this.enc(groupId)}/contacts`, { contact_id: contactId });
|
|
27085
27109
|
}
|
|
27086
|
-
async removeContactFromGroup() {
|
|
27087
|
-
|
|
27110
|
+
async removeContactFromGroup(contactId, groupId) {
|
|
27111
|
+
await this.del(`/groups/${this.enc(groupId)}/contacts/${this.enc(contactId)}`);
|
|
27088
27112
|
}
|
|
27089
|
-
async listContactsInGroup() {
|
|
27090
|
-
return
|
|
27113
|
+
async listContactsInGroup(groupId) {
|
|
27114
|
+
return pick2(await this.g(`/groups/${this.enc(groupId)}/contacts`), "contact_ids") ?? [];
|
|
27091
27115
|
}
|
|
27092
|
-
async listGroupsForContact() {
|
|
27093
|
-
return
|
|
27116
|
+
async listGroupsForContact(contactId) {
|
|
27117
|
+
return pick2(await this.g(`/groups/for-contact/${this.enc(contactId)}`), "groups") ?? [];
|
|
27094
27118
|
}
|
|
27095
|
-
async addCompanyToGroup() {
|
|
27096
|
-
return
|
|
27119
|
+
async addCompanyToGroup(companyId, groupId) {
|
|
27120
|
+
return this.post(`/groups/${this.enc(groupId)}/companies`, { company_id: companyId });
|
|
27097
27121
|
}
|
|
27098
|
-
async removeCompanyFromGroup() {
|
|
27099
|
-
|
|
27122
|
+
async removeCompanyFromGroup(companyId, groupId) {
|
|
27123
|
+
await this.del(`/groups/${this.enc(groupId)}/companies/${this.enc(companyId)}`);
|
|
27100
27124
|
}
|
|
27101
|
-
async listCompaniesInGroup() {
|
|
27102
|
-
return
|
|
27125
|
+
async listCompaniesInGroup(groupId) {
|
|
27126
|
+
return pick2(await this.g(`/groups/${this.enc(groupId)}/companies`), "company_ids") ?? [];
|
|
27103
27127
|
}
|
|
27104
|
-
async listGroupsForCompany() {
|
|
27105
|
-
return
|
|
27128
|
+
async listGroupsForCompany(companyId) {
|
|
27129
|
+
return pick2(await this.g(`/groups/for-company/${this.enc(companyId)}`), "groups") ?? [];
|
|
27106
27130
|
}
|
|
27107
|
-
async createRelationship() {
|
|
27108
|
-
return
|
|
27131
|
+
async createRelationship(input) {
|
|
27132
|
+
return pick2(await this.post("/relationships", input), "relationship");
|
|
27109
27133
|
}
|
|
27110
|
-
async listRelationships() {
|
|
27111
|
-
return
|
|
27134
|
+
async listRelationships(opts = {}) {
|
|
27135
|
+
return pick2(await this.g("/relationships", stripUndefined(opts)), "relationships") ?? [];
|
|
27112
27136
|
}
|
|
27113
|
-
async deleteRelationship() {
|
|
27114
|
-
|
|
27137
|
+
async deleteRelationship(id) {
|
|
27138
|
+
await this.del(`/relationships/${this.enc(id)}`);
|
|
27115
27139
|
}
|
|
27116
|
-
async createCompanyRelationship() {
|
|
27117
|
-
return
|
|
27140
|
+
async createCompanyRelationship(input) {
|
|
27141
|
+
return pick2(await this.post("/company-relationships", input), "relationship");
|
|
27118
27142
|
}
|
|
27119
|
-
async listCompanyRelationships() {
|
|
27120
|
-
return
|
|
27143
|
+
async listCompanyRelationships(opts = {}) {
|
|
27144
|
+
return pick2(await this.g("/company-relationships", stripUndefined(opts)), "relationships") ?? [];
|
|
27121
27145
|
}
|
|
27122
|
-
async deleteCompanyRelationship() {
|
|
27123
|
-
|
|
27146
|
+
async deleteCompanyRelationship(id) {
|
|
27147
|
+
await this.del(`/company-relationships/${this.enc(id)}`);
|
|
27124
27148
|
}
|
|
27125
|
-
async addNote() {
|
|
27126
|
-
return
|
|
27149
|
+
async addNote(contactId, body, createdBy, companyId) {
|
|
27150
|
+
return pick2(await this.post("/notes", { contact_id: contactId, body, created_by: createdBy, company_id: companyId }), "note");
|
|
27127
27151
|
}
|
|
27128
|
-
async listNotes() {
|
|
27129
|
-
return
|
|
27152
|
+
async listNotes(contactId) {
|
|
27153
|
+
return pick2(await this.g("/notes", { contact_id: contactId }), "notes") ?? [];
|
|
27130
27154
|
}
|
|
27131
|
-
async listNotesForContactAtCompany() {
|
|
27132
|
-
return
|
|
27155
|
+
async listNotesForContactAtCompany(contactId, companyId) {
|
|
27156
|
+
return pick2(await this.g("/notes", { contact_id: contactId, company_id: companyId }), "notes") ?? [];
|
|
27133
27157
|
}
|
|
27134
|
-
async deleteNote() {
|
|
27135
|
-
|
|
27158
|
+
async deleteNote(noteId) {
|
|
27159
|
+
await this.del(`/notes/${this.enc(noteId)}`);
|
|
27136
27160
|
}
|
|
27137
27161
|
async listActivity() {
|
|
27138
27162
|
return unavailable("listActivity");
|
|
@@ -27147,178 +27171,191 @@ class ApiStore {
|
|
|
27147
27171
|
};
|
|
27148
27172
|
}
|
|
27149
27173
|
async findEmailDuplicates() {
|
|
27150
|
-
return
|
|
27174
|
+
return pick2(await this.g("/email-duplicates"), "duplicates") ?? [];
|
|
27151
27175
|
}
|
|
27152
27176
|
async findNameDuplicates() {
|
|
27153
|
-
return
|
|
27154
|
-
}
|
|
27155
|
-
async flushForBackup() {
|
|
27156
|
-
return unavailable("flushForBackup");
|
|
27177
|
+
return pick2(await this.g("/name-duplicates"), "duplicates") ?? [];
|
|
27157
27178
|
}
|
|
27158
|
-
async
|
|
27159
|
-
|
|
27179
|
+
async flushForBackup() {}
|
|
27180
|
+
async listColdContacts(days) {
|
|
27181
|
+
return pick2(await this.g("/cold-contacts", { days }), "contacts") ?? [];
|
|
27160
27182
|
}
|
|
27161
|
-
async findOrCreateContact() {
|
|
27162
|
-
|
|
27183
|
+
async findOrCreateContact(input) {
|
|
27184
|
+
const emails = (input.emails ?? []).map((e) => e.address).filter(Boolean);
|
|
27185
|
+
for (const addr of emails) {
|
|
27186
|
+
const c = await this.getContactByEmail(addr);
|
|
27187
|
+
if (c)
|
|
27188
|
+
return { contact: c, created: false };
|
|
27189
|
+
}
|
|
27190
|
+
const nameQuery = input.display_name ?? (input.first_name || input.last_name ? `${input.first_name ?? ""} ${input.last_name ?? ""}`.trim() : null);
|
|
27191
|
+
if (nameQuery) {
|
|
27192
|
+
const results = await this.searchContacts(nameQuery);
|
|
27193
|
+
if (results[0])
|
|
27194
|
+
return { contact: results[0], created: false };
|
|
27195
|
+
}
|
|
27196
|
+
return { contact: await this.createContact(input), created: true };
|
|
27163
27197
|
}
|
|
27164
|
-
async findContactsForContext() {
|
|
27165
|
-
return
|
|
27198
|
+
async findContactsForContext(topic, limit) {
|
|
27199
|
+
return pick2(await this.g("/contacts-for-context", { topic, limit }), "contacts") ?? [];
|
|
27166
27200
|
}
|
|
27167
|
-
async listContactsNotContactedSince() {
|
|
27168
|
-
return
|
|
27201
|
+
async listContactsNotContactedSince(days, limit) {
|
|
27202
|
+
return pick2(await this.g("/not-contacted", { days, limit }), "contacts") ?? [];
|
|
27169
27203
|
}
|
|
27170
|
-
async listFollowupDueContacts() {
|
|
27171
|
-
return
|
|
27204
|
+
async listFollowupDueContacts(onOrBefore) {
|
|
27205
|
+
return pick2(await this.g("/followup-due-contacts", { on_or_before: onOrBefore }), "contacts") ?? [];
|
|
27172
27206
|
}
|
|
27173
|
-
async logVendorCommunication() {
|
|
27174
|
-
return
|
|
27207
|
+
async logVendorCommunication(input) {
|
|
27208
|
+
return pick2(await this.post("/vendor-comms", input), "communication");
|
|
27175
27209
|
}
|
|
27176
|
-
async listVendorCommunications() {
|
|
27177
|
-
return
|
|
27210
|
+
async listVendorCommunications(companyId, opts = {}) {
|
|
27211
|
+
return pick2(await this.g("/vendor-comms", { company_id: companyId, ...stripUndefined(opts) }), "communications") ?? [];
|
|
27178
27212
|
}
|
|
27179
27213
|
async listMissingInvoices() {
|
|
27180
|
-
return
|
|
27214
|
+
return pick2(await this.g("/vendor-comms/missing-invoices"), "communications") ?? [];
|
|
27181
27215
|
}
|
|
27182
27216
|
async listPendingFollowUps() {
|
|
27183
|
-
return
|
|
27217
|
+
return pick2(await this.g("/vendor-comms/pending-follow-ups"), "communications") ?? [];
|
|
27184
27218
|
}
|
|
27185
|
-
async markFollowUpDone() {
|
|
27186
|
-
return
|
|
27219
|
+
async markFollowUpDone(id) {
|
|
27220
|
+
return pick2(await this.post(`/vendor-comms/${this.enc(id)}/mark-done`), "communication");
|
|
27187
27221
|
}
|
|
27188
|
-
async createContactTask() {
|
|
27189
|
-
return
|
|
27222
|
+
async createContactTask(input) {
|
|
27223
|
+
return pick2(await this.post("/tasks", input), "task");
|
|
27190
27224
|
}
|
|
27191
|
-
async listContactTasks() {
|
|
27192
|
-
return
|
|
27225
|
+
async listContactTasks(opts = {}) {
|
|
27226
|
+
return pick2(await this.g("/tasks", stripUndefined(opts)), "tasks") ?? [];
|
|
27193
27227
|
}
|
|
27194
|
-
async updateContactTask() {
|
|
27195
|
-
return
|
|
27228
|
+
async updateContactTask(id, input) {
|
|
27229
|
+
return pick2(await this.patch(`/tasks/${this.enc(id)}`, input), "task");
|
|
27196
27230
|
}
|
|
27197
|
-
async deleteContactTask() {
|
|
27198
|
-
|
|
27231
|
+
async deleteContactTask(id) {
|
|
27232
|
+
await this.del(`/tasks/${this.enc(id)}`);
|
|
27199
27233
|
}
|
|
27200
27234
|
async listOverdueTasks() {
|
|
27201
|
-
return
|
|
27235
|
+
return pick2(await this.g("/tasks/overdue"), "tasks") ?? [];
|
|
27202
27236
|
}
|
|
27203
27237
|
async checkEscalations() {
|
|
27204
|
-
return
|
|
27238
|
+
return pick2(await this.g("/tasks/escalations"), "escalations") ?? [];
|
|
27205
27239
|
}
|
|
27206
|
-
async createApplication() {
|
|
27207
|
-
return
|
|
27240
|
+
async createApplication(input) {
|
|
27241
|
+
return pick2(await this.post("/applications", input), "application");
|
|
27208
27242
|
}
|
|
27209
|
-
async listApplications() {
|
|
27210
|
-
return
|
|
27243
|
+
async listApplications(opts = {}) {
|
|
27244
|
+
return pick2(await this.g("/applications", stripUndefined(opts)), "applications") ?? [];
|
|
27211
27245
|
}
|
|
27212
|
-
async updateApplication() {
|
|
27213
|
-
return
|
|
27246
|
+
async updateApplication(id, input) {
|
|
27247
|
+
return pick2(await this.patch(`/applications/${this.enc(id)}`, input), "application");
|
|
27214
27248
|
}
|
|
27215
27249
|
async listFollowUpDueApplications() {
|
|
27216
|
-
return
|
|
27250
|
+
return pick2(await this.g("/applications/follow-up-due"), "applications") ?? [];
|
|
27217
27251
|
}
|
|
27218
|
-
async addOrgMember() {
|
|
27219
|
-
return
|
|
27252
|
+
async addOrgMember(input) {
|
|
27253
|
+
return pick2(await this.post("/org-members", input), "org_member");
|
|
27220
27254
|
}
|
|
27221
|
-
async listOrgMembers() {
|
|
27222
|
-
return
|
|
27255
|
+
async listOrgMembers(companyId) {
|
|
27256
|
+
return pick2(await this.g("/org-members", { company_id: companyId }), "org_members") ?? [];
|
|
27223
27257
|
}
|
|
27224
|
-
async updateOrgMember() {
|
|
27225
|
-
return
|
|
27258
|
+
async updateOrgMember(id, input) {
|
|
27259
|
+
return pick2(await this.patch(`/org-members/${this.enc(id)}`, input), "org_member");
|
|
27226
27260
|
}
|
|
27227
|
-
async removeOrgMember() {
|
|
27228
|
-
|
|
27261
|
+
async removeOrgMember(id) {
|
|
27262
|
+
await this.del(`/org-members/${this.enc(id)}`);
|
|
27229
27263
|
}
|
|
27230
|
-
async listOrgMembersForContact() {
|
|
27231
|
-
return
|
|
27264
|
+
async listOrgMembersForContact(contactId) {
|
|
27265
|
+
return pick2(await this.g("/org-members", { contact_id: contactId }), "org_members") ?? [];
|
|
27232
27266
|
}
|
|
27233
|
-
async createDeal() {
|
|
27234
|
-
return
|
|
27267
|
+
async createDeal(input) {
|
|
27268
|
+
return pick2(await this.post("/deals", input), "deal");
|
|
27235
27269
|
}
|
|
27236
|
-
async getDeal() {
|
|
27237
|
-
return
|
|
27270
|
+
async getDeal(id) {
|
|
27271
|
+
return pick2(await this.gMaybe(`/deals/${this.enc(id)}`), "deal") ?? null;
|
|
27238
27272
|
}
|
|
27239
|
-
async listDeals() {
|
|
27240
|
-
return
|
|
27273
|
+
async listDeals(opts = {}) {
|
|
27274
|
+
return pick2(await this.g("/deals", stripUndefined(opts)), "deals") ?? [];
|
|
27241
27275
|
}
|
|
27242
|
-
async updateDeal() {
|
|
27243
|
-
return
|
|
27276
|
+
async updateDeal(id, input) {
|
|
27277
|
+
return pick2(await this.patch(`/deals/${this.enc(id)}`, input), "deal") ?? null;
|
|
27244
27278
|
}
|
|
27245
|
-
async deleteDeal() {
|
|
27246
|
-
|
|
27279
|
+
async deleteDeal(id) {
|
|
27280
|
+
await this.del(`/deals/${this.enc(id)}`);
|
|
27247
27281
|
}
|
|
27248
|
-
async logEvent() {
|
|
27249
|
-
return
|
|
27282
|
+
async logEvent(input) {
|
|
27283
|
+
return pick2(await this.post("/events", input), "event");
|
|
27250
27284
|
}
|
|
27251
|
-
async listEvents() {
|
|
27252
|
-
return
|
|
27285
|
+
async listEvents(opts = {}) {
|
|
27286
|
+
return pick2(await this.g("/events", stripUndefined(opts)), "events") ?? [];
|
|
27253
27287
|
}
|
|
27254
|
-
async deleteEvent() {
|
|
27255
|
-
|
|
27288
|
+
async deleteEvent(id) {
|
|
27289
|
+
await this.del(`/events/${this.enc(id)}`);
|
|
27256
27290
|
}
|
|
27257
|
-
async getFieldHistory() {
|
|
27258
|
-
return
|
|
27291
|
+
async getFieldHistory(contactId, fieldName) {
|
|
27292
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/field-history`, fieldName ? { field_name: fieldName } : undefined), "history") ?? [];
|
|
27259
27293
|
}
|
|
27260
|
-
async getContactAt() {
|
|
27261
|
-
return
|
|
27294
|
+
async getContactAt(contactId, timestamp) {
|
|
27295
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/field-at`, { timestamp }), "fields") ?? {};
|
|
27262
27296
|
}
|
|
27263
|
-
async addJobEntry() {
|
|
27264
|
-
return
|
|
27297
|
+
async addJobEntry(contactId, input) {
|
|
27298
|
+
return pick2(await this.post(`/contacts/${this.enc(contactId)}/job-history`, input), "job");
|
|
27265
27299
|
}
|
|
27266
|
-
async getJobHistory() {
|
|
27267
|
-
return
|
|
27300
|
+
async getJobHistory(contactId) {
|
|
27301
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/job-history`), "job_history") ?? [];
|
|
27268
27302
|
}
|
|
27269
|
-
async saveLearning() {
|
|
27270
|
-
return
|
|
27303
|
+
async saveLearning(contactId, input) {
|
|
27304
|
+
return pick2(await this.post(`/contacts/${this.enc(contactId)}/learnings`, input), "learning");
|
|
27271
27305
|
}
|
|
27272
|
-
async getLearnings() {
|
|
27273
|
-
return
|
|
27306
|
+
async getLearnings(contactId, opts = {}) {
|
|
27307
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/learnings`, stripUndefined(opts)), "learnings") ?? [];
|
|
27274
27308
|
}
|
|
27275
|
-
async searchLearnings() {
|
|
27276
|
-
return
|
|
27309
|
+
async searchLearnings(query, opts = {}) {
|
|
27310
|
+
return pick2(await this.g("/learnings/search", { q: query, ...stripUndefined(opts) }), "learnings") ?? [];
|
|
27277
27311
|
}
|
|
27278
|
-
async confirmLearning() {
|
|
27279
|
-
|
|
27312
|
+
async confirmLearning(learningId) {
|
|
27313
|
+
await this.post(`/learnings/${this.enc(learningId)}/confirm`);
|
|
27280
27314
|
}
|
|
27281
|
-
async getStaleLearnings() {
|
|
27282
|
-
return
|
|
27315
|
+
async getStaleLearnings(daysOld, minConfidence) {
|
|
27316
|
+
return pick2(await this.g("/learnings/stale", { days_old: daysOld, min_confidence: minConfidence }), "learnings") ?? [];
|
|
27283
27317
|
}
|
|
27284
27318
|
async runLearningMaintenance() {
|
|
27285
|
-
|
|
27319
|
+
const r = await this.post("/learnings/maintenance");
|
|
27320
|
+
return { decayed_count: Number(r?.decayed_count ?? 0), potential_contradictions: r?.potential_contradictions ?? [] };
|
|
27286
27321
|
}
|
|
27287
|
-
async acquireContactLock() {
|
|
27288
|
-
return
|
|
27322
|
+
async acquireContactLock(contactId, agentName, ttlSeconds, reason, sessionId) {
|
|
27323
|
+
return this.post("/locks", { contact_id: contactId, agent_name: agentName, ttl_seconds: ttlSeconds, reason, session_id: sessionId });
|
|
27289
27324
|
}
|
|
27290
|
-
async releaseContactLock() {
|
|
27291
|
-
|
|
27325
|
+
async releaseContactLock(contactId, agentName) {
|
|
27326
|
+
const r = await this.del(`/locks/${this.enc(contactId)}`, { agent_name: agentName });
|
|
27327
|
+
return Boolean(r?.released);
|
|
27292
27328
|
}
|
|
27293
|
-
async checkContactLock() {
|
|
27294
|
-
return
|
|
27329
|
+
async checkContactLock(contactId) {
|
|
27330
|
+
return pick2(await this.g(`/locks/${this.enc(contactId)}`), "lock") ?? null;
|
|
27295
27331
|
}
|
|
27296
|
-
async logAgentActivity() {
|
|
27297
|
-
|
|
27332
|
+
async logAgentActivity(contactId, agentName, action, details, sessionId) {
|
|
27333
|
+
await this.post("/activity", { contact_id: contactId, agent_name: agentName, action, details, session_id: sessionId });
|
|
27298
27334
|
}
|
|
27299
|
-
async getAgentActivity() {
|
|
27300
|
-
return
|
|
27335
|
+
async getAgentActivity(contactId, limit) {
|
|
27336
|
+
return pick2(await this.g("/activity", { contact_id: contactId, limit }), "activity") ?? [];
|
|
27301
27337
|
}
|
|
27302
|
-
async computeRelationshipStrength() {
|
|
27303
|
-
|
|
27338
|
+
async computeRelationshipStrength(contactId) {
|
|
27339
|
+
const r = await this.g(`/graph/strength/${this.enc(contactId)}`);
|
|
27340
|
+
return Number(r?.strength ?? 0);
|
|
27304
27341
|
}
|
|
27305
|
-
async findWarmPath() {
|
|
27306
|
-
return
|
|
27342
|
+
async findWarmPath(fromContactId, toContactId) {
|
|
27343
|
+
return pick2(await this.g("/graph/warm-path", { from: fromContactId, to: toContactId }), "path") ?? [];
|
|
27307
27344
|
}
|
|
27308
|
-
async findConnectionsAtCompany() {
|
|
27309
|
-
return
|
|
27345
|
+
async findConnectionsAtCompany(companyId) {
|
|
27346
|
+
return pick2(await this.g(`/graph/company/${this.enc(companyId)}`), "connections") ?? [];
|
|
27310
27347
|
}
|
|
27311
27348
|
async detectCoolingRelationships() {
|
|
27312
|
-
return
|
|
27349
|
+
return pick2(await this.g("/graph/cooling"), "cooling") ?? [];
|
|
27313
27350
|
}
|
|
27314
|
-
async resolveContactIdentity() {
|
|
27315
|
-
return
|
|
27351
|
+
async resolveContactIdentity(partial2) {
|
|
27352
|
+
return pick2(await this.post("/identity/resolve", partial2), "matches") ?? [];
|
|
27316
27353
|
}
|
|
27317
|
-
async addContactIdentity() {
|
|
27318
|
-
return
|
|
27354
|
+
async addContactIdentity(contactId, system, externalId, externalUrl, confidence = "inferred") {
|
|
27355
|
+
return pick2(await this.post("/identity", { contact_id: contactId, system, external_id: externalId, external_url: externalUrl, confidence }), "identity");
|
|
27319
27356
|
}
|
|
27320
|
-
async getContactIdentities() {
|
|
27321
|
-
return
|
|
27357
|
+
async getContactIdentities(contactId) {
|
|
27358
|
+
return pick2(await this.g("/identity", { contact_id: contactId }), "identities") ?? [];
|
|
27322
27359
|
}
|
|
27323
27360
|
async semanticSearch() {
|
|
27324
27361
|
return unavailable("semanticSearch");
|
|
@@ -27329,44 +27366,45 @@ class ApiStore {
|
|
|
27329
27366
|
async embedAllContacts() {
|
|
27330
27367
|
return unavailable("embedAllContacts");
|
|
27331
27368
|
}
|
|
27332
|
-
async getRelationshipSignals() {
|
|
27333
|
-
return
|
|
27369
|
+
async getRelationshipSignals(contactId) {
|
|
27370
|
+
return pick2(await this.g("/signals", { contact_id: contactId }), "signals") ?? [];
|
|
27334
27371
|
}
|
|
27335
27372
|
async getGhostContacts() {
|
|
27336
|
-
return
|
|
27373
|
+
return pick2(await this.g("/signals/ghost"), "signals") ?? [];
|
|
27337
27374
|
}
|
|
27338
27375
|
async getWarmingContacts() {
|
|
27339
|
-
return
|
|
27376
|
+
return pick2(await this.g("/signals/warming"), "signals") ?? [];
|
|
27340
27377
|
}
|
|
27341
27378
|
async recomputeSignals() {
|
|
27342
|
-
|
|
27379
|
+
const r = await this.post("/signals/recompute");
|
|
27380
|
+
return { updated: Number(r?.updated ?? 0) };
|
|
27343
27381
|
}
|
|
27344
|
-
async getFreshnessScore() {
|
|
27345
|
-
return
|
|
27382
|
+
async getFreshnessScore(contactId) {
|
|
27383
|
+
return pick2(await this.g(`/freshness/${this.enc(contactId)}`), "freshness");
|
|
27346
27384
|
}
|
|
27347
|
-
async getStaleContacts() {
|
|
27348
|
-
return
|
|
27385
|
+
async getStaleContacts(threshold) {
|
|
27386
|
+
return pick2(await this.g("/freshness/stale", { threshold }), "contacts") ?? [];
|
|
27349
27387
|
}
|
|
27350
|
-
async markFieldVerified() {
|
|
27351
|
-
|
|
27388
|
+
async markFieldVerified(contactId, fieldName, source) {
|
|
27389
|
+
await this.post("/freshness/verify", { contact_id: contactId, field_name: fieldName, source });
|
|
27352
27390
|
}
|
|
27353
|
-
async addOrgChartEdge() {
|
|
27354
|
-
return
|
|
27391
|
+
async addOrgChartEdge(companyId, contactAId, contactBId, edgeType, inferred = false) {
|
|
27392
|
+
return pick2(await this.post("/org-chart", { company_id: companyId, contact_a_id: contactAId, contact_b_id: contactBId, edge_type: edgeType, inferred }), "edge");
|
|
27355
27393
|
}
|
|
27356
|
-
async listOrgChart() {
|
|
27357
|
-
return
|
|
27394
|
+
async listOrgChart(companyId) {
|
|
27395
|
+
return pick2(await this.g("/org-chart", { company_id: companyId }), "edges") ?? [];
|
|
27358
27396
|
}
|
|
27359
|
-
async setDealContactRole() {
|
|
27360
|
-
return
|
|
27397
|
+
async setDealContactRole(dealId, contactId, accountRole) {
|
|
27398
|
+
return pick2(await this.post(`/deals/${this.enc(dealId)}/roles`, { contact_id: contactId, account_role: accountRole }), "role");
|
|
27361
27399
|
}
|
|
27362
|
-
async getDealTeam() {
|
|
27363
|
-
return
|
|
27400
|
+
async getDealTeam(dealId) {
|
|
27401
|
+
return pick2(await this.g(`/deals/${this.enc(dealId)}/team`), "team") ?? [];
|
|
27364
27402
|
}
|
|
27365
|
-
async getCoverageGaps() {
|
|
27366
|
-
return
|
|
27403
|
+
async getCoverageGaps(companyId) {
|
|
27404
|
+
return pick2(await this.g(`/org-chart/coverage/${this.enc(companyId)}`), "coverage");
|
|
27367
27405
|
}
|
|
27368
|
-
async getRecentContactEvents() {
|
|
27369
|
-
return
|
|
27406
|
+
async getRecentContactEvents(since, eventTypes) {
|
|
27407
|
+
return pick2(await this.g("/recent-events", { since, types: eventTypes?.length ? eventTypes.join(",") : undefined }), "events") ?? [];
|
|
27370
27408
|
}
|
|
27371
27409
|
async addDocument() {
|
|
27372
27410
|
return unavailable("addDocument");
|
|
@@ -27392,65 +27430,66 @@ class ApiStore {
|
|
|
27392
27430
|
async deleteHealthData() {
|
|
27393
27431
|
return unavailable("deleteHealthData");
|
|
27394
27432
|
}
|
|
27395
|
-
async createAudience() {
|
|
27396
|
-
return
|
|
27433
|
+
async createAudience(input) {
|
|
27434
|
+
return pick2(await this.post("/audiences", input), "audience");
|
|
27397
27435
|
}
|
|
27398
|
-
async getAudience() {
|
|
27399
|
-
return
|
|
27436
|
+
async getAudience(idOrSlug) {
|
|
27437
|
+
return pick2(await this.g(`/audiences/${this.enc(idOrSlug)}`), "audience");
|
|
27400
27438
|
}
|
|
27401
27439
|
async listAudiences() {
|
|
27402
|
-
return
|
|
27440
|
+
return pick2(await this.g("/audiences"), "audiences") ?? [];
|
|
27403
27441
|
}
|
|
27404
|
-
async updateAudience() {
|
|
27405
|
-
return
|
|
27442
|
+
async updateAudience(idOrSlug, input) {
|
|
27443
|
+
return pick2(await this.patch(`/audiences/${this.enc(idOrSlug)}`, input), "audience");
|
|
27406
27444
|
}
|
|
27407
|
-
async deleteAudience() {
|
|
27408
|
-
|
|
27445
|
+
async deleteAudience(idOrSlug) {
|
|
27446
|
+
await this.del(`/audiences/${this.enc(idOrSlug)}`);
|
|
27409
27447
|
}
|
|
27410
|
-
async resolveAudience() {
|
|
27411
|
-
return
|
|
27448
|
+
async resolveAudience(idOrSlug, channel) {
|
|
27449
|
+
return pick2(await this.g(`/audiences/${this.enc(idOrSlug)}/resolve`, { channel }), "resolution");
|
|
27412
27450
|
}
|
|
27413
|
-
async setContactConsent() {
|
|
27414
|
-
return
|
|
27451
|
+
async setContactConsent(contactId, channel, status, source) {
|
|
27452
|
+
return pick2(await this.post("/consent", { contact_id: contactId, channel, status, source }), "consent");
|
|
27415
27453
|
}
|
|
27416
|
-
async listContactConsent() {
|
|
27417
|
-
return
|
|
27454
|
+
async listContactConsent(contactId) {
|
|
27455
|
+
return pick2(await this.g("/consent", { contact_id: contactId }), "consent") ?? [];
|
|
27418
27456
|
}
|
|
27419
|
-
async suppressAddress() {
|
|
27420
|
-
return
|
|
27457
|
+
async suppressAddress(input) {
|
|
27458
|
+
return pick2(await this.post("/suppressions", input), "suppression");
|
|
27421
27459
|
}
|
|
27422
|
-
async unsuppressAddress() {
|
|
27423
|
-
|
|
27460
|
+
async unsuppressAddress(channel, address) {
|
|
27461
|
+
await this.del("/suppressions", { channel, address });
|
|
27424
27462
|
}
|
|
27425
|
-
async listSuppressions() {
|
|
27426
|
-
return
|
|
27463
|
+
async listSuppressions(opts = {}) {
|
|
27464
|
+
return pick2(await this.g("/suppressions", stripUndefined(opts)), "suppressions") ?? [];
|
|
27427
27465
|
}
|
|
27428
27466
|
async syncSuppressions() {
|
|
27429
27467
|
return unavailable("syncSuppressions");
|
|
27430
27468
|
}
|
|
27431
|
-
async generateBrief() {
|
|
27432
|
-
|
|
27469
|
+
async generateBrief(contactId) {
|
|
27470
|
+
const r = await this.g(`/contacts/${this.enc(contactId)}/brief-text`);
|
|
27471
|
+
return String(r?.text ?? "");
|
|
27433
27472
|
}
|
|
27434
|
-
async getContactCard() {
|
|
27435
|
-
return
|
|
27473
|
+
async getContactCard(contactId) {
|
|
27474
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/card`), "card");
|
|
27436
27475
|
}
|
|
27437
|
-
async getContactBrief() {
|
|
27438
|
-
return
|
|
27476
|
+
async getContactBrief(contactId, taskContext) {
|
|
27477
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/brief`, taskContext ? { context: taskContext } : undefined), "brief");
|
|
27439
27478
|
}
|
|
27440
|
-
async assembleContext() {
|
|
27441
|
-
return
|
|
27479
|
+
async assembleContext(contactIds, format) {
|
|
27480
|
+
return pick2(await this.post("/assemble-context", { contact_ids: contactIds, format }), "context");
|
|
27442
27481
|
}
|
|
27443
|
-
async getUpcomingItems() {
|
|
27444
|
-
return
|
|
27482
|
+
async getUpcomingItems(days) {
|
|
27483
|
+
return pick2(await this.g("/upcoming", { days }), "items") ?? [];
|
|
27445
27484
|
}
|
|
27446
27485
|
async getNetworkStats() {
|
|
27447
|
-
return
|
|
27486
|
+
return pick2(await this.g("/network-stats"), "stats");
|
|
27448
27487
|
}
|
|
27449
27488
|
async listContactAudit() {
|
|
27450
|
-
return
|
|
27489
|
+
return pick2(await this.g("/contact-audit"), "audit") ?? [];
|
|
27451
27490
|
}
|
|
27452
|
-
async getContactTimeline() {
|
|
27453
|
-
return
|
|
27491
|
+
async getContactTimeline(contactId, limit) {
|
|
27492
|
+
return pick2(await this.g(`/contacts/${this.enc(contactId)}/timeline`, { limit }), "timeline") ?? [];
|
|
27454
27493
|
}
|
|
27455
27494
|
async ingestMeetingParticipants() {
|
|
27456
27495
|
return unavailable("ingestMeetingParticipants");
|
|
@@ -27480,13 +27519,13 @@ class ApiStore {
|
|
|
27480
27519
|
return unavailable("lockVault");
|
|
27481
27520
|
}
|
|
27482
27521
|
async isVaultInitialized() {
|
|
27483
|
-
return
|
|
27522
|
+
return false;
|
|
27484
27523
|
}
|
|
27485
27524
|
async isVaultUnlocked() {
|
|
27486
|
-
return
|
|
27525
|
+
return false;
|
|
27487
27526
|
}
|
|
27488
27527
|
async vaultStatus() {
|
|
27489
|
-
return
|
|
27528
|
+
return pick2(await this.g("/vault-status"), "vault") ?? { initialized: false, unlocked: false, document_count: 0 };
|
|
27490
27529
|
}
|
|
27491
27530
|
async saveFeedback() {
|
|
27492
27531
|
return unavailable("saveFeedback");
|
|
@@ -27495,7 +27534,7 @@ class ApiStore {
|
|
|
27495
27534
|
return null;
|
|
27496
27535
|
}
|
|
27497
27536
|
async listActiveWebhooks() {
|
|
27498
|
-
return
|
|
27537
|
+
return [];
|
|
27499
27538
|
}
|
|
27500
27539
|
}
|
|
27501
27540
|
var cached2;
|
|
@@ -31112,6 +31151,32 @@ function iso(value) {
|
|
|
31112
31151
|
return value.toISOString();
|
|
31113
31152
|
return typeof value === "string" ? value : String(value ?? "");
|
|
31114
31153
|
}
|
|
31154
|
+
function isoOrNull(value) {
|
|
31155
|
+
if (value === null || value === undefined)
|
|
31156
|
+
return null;
|
|
31157
|
+
return iso(value);
|
|
31158
|
+
}
|
|
31159
|
+
function pj(value, fallback) {
|
|
31160
|
+
if (value === null || value === undefined)
|
|
31161
|
+
return fallback;
|
|
31162
|
+
if (typeof value === "string") {
|
|
31163
|
+
if (value === "")
|
|
31164
|
+
return fallback;
|
|
31165
|
+
try {
|
|
31166
|
+
const parsed = JSON.parse(value);
|
|
31167
|
+
return parsed ?? fallback;
|
|
31168
|
+
} catch {
|
|
31169
|
+
return fallback;
|
|
31170
|
+
}
|
|
31171
|
+
}
|
|
31172
|
+
return value;
|
|
31173
|
+
}
|
|
31174
|
+
function newUuid() {
|
|
31175
|
+
return crypto.randomUUID();
|
|
31176
|
+
}
|
|
31177
|
+
function nowIso() {
|
|
31178
|
+
return new Date().toISOString();
|
|
31179
|
+
}
|
|
31115
31180
|
function parseJson(value) {
|
|
31116
31181
|
if (!value)
|
|
31117
31182
|
return {};
|
|
@@ -31409,114 +31474,1483 @@ class ContactsPgStore {
|
|
|
31409
31474
|
tags: Number(row?.tags ?? 0)
|
|
31410
31475
|
};
|
|
31411
31476
|
}
|
|
31412
|
-
|
|
31413
|
-
|
|
31414
|
-
|
|
31415
|
-
|
|
31416
|
-
|
|
31417
|
-
|
|
31418
|
-
|
|
31419
|
-
|
|
31420
|
-
|
|
31421
|
-
|
|
31422
|
-
|
|
31423
|
-
|
|
31424
|
-
|
|
31425
|
-
|
|
31426
|
-
|
|
31427
|
-
}
|
|
31428
|
-
async function readJson(req) {
|
|
31429
|
-
try {
|
|
31430
|
-
const text = await req.text();
|
|
31431
|
-
if (!text)
|
|
31432
|
-
return {};
|
|
31433
|
-
return JSON.parse(text);
|
|
31434
|
-
} catch {
|
|
31435
|
-
return null;
|
|
31477
|
+
async loadDetails(contact) {
|
|
31478
|
+
const [emails, phones, tags, company] = await Promise.all([
|
|
31479
|
+
this.client.many(`SELECT * FROM emails WHERE contact_id = $1`, [contact.id]),
|
|
31480
|
+
this.client.many(`SELECT * FROM phones WHERE contact_id = $1`, [contact.id]),
|
|
31481
|
+
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]),
|
|
31482
|
+
contact.company_id ? this.client.get(`SELECT * FROM companies WHERE id = $1`, [contact.company_id]) : Promise.resolve(null)
|
|
31483
|
+
]);
|
|
31484
|
+
return {
|
|
31485
|
+
...contact,
|
|
31486
|
+
emails: emails.map((e) => ({ ...e, is_primary: Boolean(e.is_primary), created_at: isoOrNull(e.created_at) })),
|
|
31487
|
+
phones: phones.map((p) => ({ ...p, is_primary: Boolean(p.is_primary), created_at: isoOrNull(p.created_at) })),
|
|
31488
|
+
addresses: [],
|
|
31489
|
+
social_profiles: [],
|
|
31490
|
+
tags: tags.map((t) => mapTag(t)),
|
|
31491
|
+
company: company ? mapCompany(company) : null
|
|
31492
|
+
};
|
|
31436
31493
|
}
|
|
31437
|
-
|
|
31438
|
-
|
|
31439
|
-
|
|
31440
|
-
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
31441
|
-
return null;
|
|
31442
|
-
const method = req.method.toUpperCase();
|
|
31443
|
-
const isWrite = method !== "GET" && method !== "HEAD";
|
|
31444
|
-
const requiredScopes = [isWrite ? `${CONTACTS_APP_SLUG}:write` : `${CONTACTS_APP_SLUG}:read`];
|
|
31445
|
-
let verifier;
|
|
31446
|
-
try {
|
|
31447
|
-
verifier = getCloudVerifier();
|
|
31448
|
-
} catch (e) {
|
|
31449
|
-
return error2(503, e.message);
|
|
31494
|
+
async searchContacts(q) {
|
|
31495
|
+
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}%`]);
|
|
31496
|
+
return Promise.all(rows.map((r) => this.loadDetails(mapContact(r))));
|
|
31450
31497
|
}
|
|
31451
|
-
|
|
31452
|
-
|
|
31453
|
-
|
|
31498
|
+
async listColdContacts(days) {
|
|
31499
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
31500
|
+
const rows = await this.client.many(`SELECT * FROM contacts
|
|
31501
|
+
WHERE archived = false AND do_not_contact = false
|
|
31502
|
+
AND (last_contacted_at IS NULL OR last_contacted_at < $1)
|
|
31503
|
+
ORDER BY last_contacted_at ASC NULLS FIRST LIMIT 100`, [cutoff]);
|
|
31504
|
+
return Promise.all(rows.map((r) => this.loadDetails(mapContact(r))));
|
|
31454
31505
|
}
|
|
31455
|
-
|
|
31456
|
-
|
|
31457
|
-
|
|
31458
|
-
|
|
31459
|
-
|
|
31460
|
-
|
|
31461
|
-
|
|
31462
|
-
|
|
31463
|
-
|
|
31464
|
-
|
|
31465
|
-
|
|
31466
|
-
|
|
31467
|
-
|
|
31468
|
-
|
|
31469
|
-
|
|
31470
|
-
|
|
31471
|
-
|
|
31472
|
-
|
|
31473
|
-
|
|
31474
|
-
|
|
31475
|
-
|
|
31476
|
-
|
|
31477
|
-
|
|
31478
|
-
|
|
31479
|
-
|
|
31480
|
-
|
|
31481
|
-
|
|
31482
|
-
|
|
31483
|
-
|
|
31484
|
-
|
|
31485
|
-
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
|
|
31493
|
-
|
|
31494
|
-
|
|
31495
|
-
|
|
31506
|
+
async listContactsNotContactedSince(days, limit) {
|
|
31507
|
+
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
31508
|
+
return this.client.many(`SELECT id, display_name, last_contacted_at FROM contacts
|
|
31509
|
+
WHERE (last_contacted_at IS NULL OR last_contacted_at < $1) AND archived = false LIMIT $2`, [cutoff, Math.max(1, limit)]);
|
|
31510
|
+
}
|
|
31511
|
+
async listFollowupDueContacts(onOrBefore) {
|
|
31512
|
+
return this.client.many(`SELECT id, display_name, follow_up_at FROM contacts
|
|
31513
|
+
WHERE follow_up_at IS NOT NULL AND follow_up_at <= $1 AND archived = false`, [onOrBefore]);
|
|
31514
|
+
}
|
|
31515
|
+
async findContactsForContext(topic, limit) {
|
|
31516
|
+
const like = `%${topic}%`;
|
|
31517
|
+
const [byTitle, byNotes, byCompany, bySpec] = await Promise.all([
|
|
31518
|
+
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]),
|
|
31519
|
+
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]),
|
|
31520
|
+
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]),
|
|
31521
|
+
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])
|
|
31522
|
+
]);
|
|
31523
|
+
const seen = new Set;
|
|
31524
|
+
return [...byTitle, ...bySpec, ...byCompany, ...byNotes].filter((r) => {
|
|
31525
|
+
if (seen.has(r.id))
|
|
31526
|
+
return false;
|
|
31527
|
+
seen.add(r.id);
|
|
31528
|
+
return true;
|
|
31529
|
+
}).slice(0, limit);
|
|
31530
|
+
}
|
|
31531
|
+
async findEmailDuplicates() {
|
|
31532
|
+
const rows = await this.client.many(`SELECT MIN(e.address) AS email, string_agg(e.contact_id, ',') AS ids
|
|
31533
|
+
FROM emails e WHERE e.contact_id IS NOT NULL
|
|
31534
|
+
GROUP BY LOWER(e.address) HAVING COUNT(*) > 1`);
|
|
31535
|
+
return rows.map((r) => ({ email: r.email, contact_ids: (r.ids ?? "").split(",").filter(Boolean) }));
|
|
31536
|
+
}
|
|
31537
|
+
async findNameDuplicates() {
|
|
31538
|
+
const contacts = await this.client.many(`SELECT id, display_name FROM contacts`);
|
|
31539
|
+
const lev = (a, b) => {
|
|
31540
|
+
const m = a.length, n = b.length;
|
|
31541
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
31542
|
+
for (let i = 1;i <= m; i++)
|
|
31543
|
+
for (let j = 1;j <= n; j++)
|
|
31544
|
+
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]);
|
|
31545
|
+
return dp[m][n];
|
|
31546
|
+
};
|
|
31547
|
+
const pairs = [];
|
|
31548
|
+
for (let i = 0;i < contacts.length; i++) {
|
|
31549
|
+
for (let j = i + 1;j < contacts.length; j++) {
|
|
31550
|
+
const dist = lev(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
|
|
31551
|
+
if (dist <= 2 && dist > 0)
|
|
31552
|
+
pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
|
|
31496
31553
|
}
|
|
31497
|
-
return error2(405, `method ${method} not allowed on /v1/contacts/:id`);
|
|
31498
31554
|
}
|
|
31499
|
-
|
|
31500
|
-
|
|
31501
|
-
|
|
31502
|
-
|
|
31503
|
-
|
|
31504
|
-
|
|
31505
|
-
|
|
31506
|
-
|
|
31507
|
-
|
|
31508
|
-
|
|
31509
|
-
|
|
31510
|
-
|
|
31511
|
-
|
|
31512
|
-
|
|
31513
|
-
|
|
31514
|
-
|
|
31515
|
-
|
|
31516
|
-
|
|
31517
|
-
|
|
31518
|
-
|
|
31519
|
-
|
|
31555
|
+
return pairs;
|
|
31556
|
+
}
|
|
31557
|
+
async getRecentContactEvents(since, eventTypes) {
|
|
31558
|
+
const params = [];
|
|
31559
|
+
let sql = `SELECT * FROM activity_log WHERE 1=1`;
|
|
31560
|
+
if (since) {
|
|
31561
|
+
params.push(since);
|
|
31562
|
+
sql += ` AND created_at >= $${params.length}`;
|
|
31563
|
+
}
|
|
31564
|
+
if (eventTypes?.length) {
|
|
31565
|
+
const placeholders = eventTypes.map((_, i) => `$${params.length + i + 1}`);
|
|
31566
|
+
params.push(...eventTypes);
|
|
31567
|
+
sql += ` AND action IN (${placeholders.join(",")})`;
|
|
31568
|
+
}
|
|
31569
|
+
sql += ` ORDER BY created_at DESC LIMIT 100`;
|
|
31570
|
+
const rows = await this.client.many(sql, params);
|
|
31571
|
+
return rows.map((r) => ({ ...r, created_at: isoOrNull(r.created_at) }));
|
|
31572
|
+
}
|
|
31573
|
+
mapDeal(r) {
|
|
31574
|
+
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) };
|
|
31575
|
+
}
|
|
31576
|
+
async createDeal(input) {
|
|
31577
|
+
const id = newUuid();
|
|
31578
|
+
const row = await this.client.get(`INSERT INTO deals (id, title, contact_id, company_id, stage, value_usd, currency, close_date, notes)
|
|
31579
|
+
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]);
|
|
31580
|
+
return this.mapDeal(row);
|
|
31581
|
+
}
|
|
31582
|
+
async getDeal(id) {
|
|
31583
|
+
const row = await this.client.get(`SELECT * FROM deals WHERE id = $1`, [id]);
|
|
31584
|
+
return row ? this.mapDeal(row) : null;
|
|
31585
|
+
}
|
|
31586
|
+
async listDeals(opts = {}) {
|
|
31587
|
+
const where = [];
|
|
31588
|
+
const params = [];
|
|
31589
|
+
if (opts.stage) {
|
|
31590
|
+
params.push(opts.stage);
|
|
31591
|
+
where.push(`stage = $${params.length}`);
|
|
31592
|
+
}
|
|
31593
|
+
if (opts.contact_id) {
|
|
31594
|
+
params.push(opts.contact_id);
|
|
31595
|
+
where.push(`contact_id = $${params.length}`);
|
|
31596
|
+
}
|
|
31597
|
+
if (opts.company_id) {
|
|
31598
|
+
params.push(opts.company_id);
|
|
31599
|
+
where.push(`company_id = $${params.length}`);
|
|
31600
|
+
}
|
|
31601
|
+
const sql = `SELECT * FROM deals ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
31602
|
+
return (await this.client.many(sql, params)).map((r) => this.mapDeal(r));
|
|
31603
|
+
}
|
|
31604
|
+
async updateDeal(id, input) {
|
|
31605
|
+
const existing = await this.getDeal(id);
|
|
31606
|
+
if (!existing)
|
|
31607
|
+
return null;
|
|
31608
|
+
const cols = ["title", "contact_id", "company_id", "stage", "value_usd", "currency", "close_date", "notes"];
|
|
31609
|
+
const sets = [];
|
|
31610
|
+
const params = [id];
|
|
31611
|
+
for (const c of cols)
|
|
31612
|
+
if (c in input) {
|
|
31613
|
+
params.push(input[c] ?? null);
|
|
31614
|
+
sets.push(`${c} = $${params.length}`);
|
|
31615
|
+
}
|
|
31616
|
+
sets.push(`updated_at = NOW()`);
|
|
31617
|
+
const row = await this.client.get(`UPDATE deals SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
31618
|
+
return row ? this.mapDeal(row) : null;
|
|
31619
|
+
}
|
|
31620
|
+
async deleteDeal(id) {
|
|
31621
|
+
return (await this.client.query(`DELETE FROM deals WHERE id = $1`, [id])).rowCount > 0;
|
|
31622
|
+
}
|
|
31623
|
+
mapEvent(r) {
|
|
31624
|
+
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) };
|
|
31625
|
+
}
|
|
31626
|
+
async logEvent(input) {
|
|
31627
|
+
const id = newUuid();
|
|
31628
|
+
const row = await this.client.get(`INSERT INTO events (id, title, type, event_date, duration_min, contact_ids, company_id, notes, outcome, deal_id)
|
|
31629
|
+
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]);
|
|
31630
|
+
return this.mapEvent(row);
|
|
31631
|
+
}
|
|
31632
|
+
async listEvents(opts = {}) {
|
|
31633
|
+
const where = [];
|
|
31634
|
+
const params = [];
|
|
31635
|
+
if (opts.contact_id) {
|
|
31636
|
+
params.push(`%${opts.contact_id}%`);
|
|
31637
|
+
where.push(`contact_ids LIKE $${params.length}`);
|
|
31638
|
+
}
|
|
31639
|
+
if (opts.company_id) {
|
|
31640
|
+
params.push(opts.company_id);
|
|
31641
|
+
where.push(`company_id = $${params.length}`);
|
|
31642
|
+
}
|
|
31643
|
+
if (opts.type) {
|
|
31644
|
+
params.push(opts.type);
|
|
31645
|
+
where.push(`type = $${params.length}`);
|
|
31646
|
+
}
|
|
31647
|
+
if (opts.date_from) {
|
|
31648
|
+
params.push(opts.date_from);
|
|
31649
|
+
where.push(`event_date >= $${params.length}`);
|
|
31650
|
+
}
|
|
31651
|
+
if (opts.date_to) {
|
|
31652
|
+
params.push(opts.date_to);
|
|
31653
|
+
where.push(`event_date <= $${params.length}`);
|
|
31654
|
+
}
|
|
31655
|
+
const sql = `SELECT * FROM events ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY event_date DESC`;
|
|
31656
|
+
return (await this.client.many(sql, params)).map((r) => this.mapEvent(r));
|
|
31657
|
+
}
|
|
31658
|
+
async deleteEvent(id) {
|
|
31659
|
+
return (await this.client.query(`DELETE FROM events WHERE id = $1`, [id])).rowCount > 0;
|
|
31660
|
+
}
|
|
31661
|
+
mapTask(r) {
|
|
31662
|
+
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) };
|
|
31663
|
+
}
|
|
31664
|
+
async createContactTask(input) {
|
|
31665
|
+
const id = newUuid();
|
|
31666
|
+
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)
|
|
31667
|
+
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 ?? [])]);
|
|
31668
|
+
return this.mapTask(row);
|
|
31669
|
+
}
|
|
31670
|
+
async listContactTasks(opts = {}) {
|
|
31671
|
+
const where = [];
|
|
31672
|
+
const params = [];
|
|
31673
|
+
if (opts.contact_id) {
|
|
31674
|
+
params.push(opts.contact_id);
|
|
31675
|
+
where.push(`contact_id = $${params.length}`);
|
|
31676
|
+
}
|
|
31677
|
+
if (opts.entity_id) {
|
|
31678
|
+
params.push(opts.entity_id);
|
|
31679
|
+
where.push(`entity_id = $${params.length}`);
|
|
31680
|
+
}
|
|
31681
|
+
if (opts.status) {
|
|
31682
|
+
params.push(opts.status);
|
|
31683
|
+
where.push(`status = $${params.length}`);
|
|
31684
|
+
}
|
|
31685
|
+
if (opts.priority) {
|
|
31686
|
+
params.push(opts.priority);
|
|
31687
|
+
where.push(`priority = $${params.length}`);
|
|
31688
|
+
}
|
|
31689
|
+
const sql = `SELECT * FROM contact_tasks ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY deadline ASC NULLS LAST, priority DESC, created_at ASC`;
|
|
31690
|
+
return (await this.client.many(sql, params)).map((r) => this.mapTask(r));
|
|
31691
|
+
}
|
|
31692
|
+
async updateContactTask(id, input) {
|
|
31693
|
+
const cols = ["title", "description", "assigned_by", "deadline", "status", "priority", "entity_id", "linked_todos_task_id"];
|
|
31694
|
+
const sets = [];
|
|
31695
|
+
const params = [id];
|
|
31696
|
+
for (const c of cols)
|
|
31697
|
+
if (c in input) {
|
|
31698
|
+
params.push(input[c] ?? null);
|
|
31699
|
+
sets.push(`${c} = $${params.length}`);
|
|
31700
|
+
}
|
|
31701
|
+
if ("escalation_rules" in input) {
|
|
31702
|
+
params.push(JSON.stringify(input.escalation_rules));
|
|
31703
|
+
sets.push(`escalation_rules = $${params.length}`);
|
|
31704
|
+
}
|
|
31705
|
+
sets.push(`updated_at = NOW()`);
|
|
31706
|
+
const row = await this.client.get(`UPDATE contact_tasks SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
31707
|
+
return row ? this.mapTask(row) : null;
|
|
31708
|
+
}
|
|
31709
|
+
async deleteContactTask(id) {
|
|
31710
|
+
return (await this.client.query(`DELETE FROM contact_tasks WHERE id = $1`, [id])).rowCount > 0;
|
|
31711
|
+
}
|
|
31712
|
+
async listOverdueTasks() {
|
|
31713
|
+
const rows = await this.client.many(`SELECT * FROM contact_tasks WHERE deadline < $1 AND status NOT IN ('completed','cancelled') ORDER BY deadline ASC`, [nowIso()]);
|
|
31714
|
+
return rows.map((r) => this.mapTask(r));
|
|
31715
|
+
}
|
|
31716
|
+
async checkEscalations() {
|
|
31717
|
+
const overdue = await this.listOverdueTasks();
|
|
31718
|
+
const nowMs = Date.now();
|
|
31719
|
+
const results = [];
|
|
31720
|
+
for (const task of overdue) {
|
|
31721
|
+
const rules = task.escalation_rules ?? [];
|
|
31722
|
+
if (!task.deadline || rules.length === 0)
|
|
31723
|
+
continue;
|
|
31724
|
+
const days = (nowMs - new Date(task.deadline).getTime()) / 86400000;
|
|
31725
|
+
const triggered = rules.filter((r) => days >= r.after_days);
|
|
31726
|
+
if (triggered.length)
|
|
31727
|
+
results.push({ task, rules_triggered: triggered });
|
|
31728
|
+
}
|
|
31729
|
+
return results;
|
|
31730
|
+
}
|
|
31731
|
+
mapApplication(r) {
|
|
31732
|
+
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) };
|
|
31733
|
+
}
|
|
31734
|
+
async createApplication(input) {
|
|
31735
|
+
const id = newUuid();
|
|
31736
|
+
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)
|
|
31737
|
+
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 ?? {})]);
|
|
31738
|
+
return this.mapApplication(row);
|
|
31739
|
+
}
|
|
31740
|
+
async listApplications(opts = {}) {
|
|
31741
|
+
const where = [];
|
|
31742
|
+
const params = [];
|
|
31743
|
+
if (opts.type) {
|
|
31744
|
+
params.push(opts.type);
|
|
31745
|
+
where.push(`type = $${params.length}`);
|
|
31746
|
+
}
|
|
31747
|
+
if (opts.status) {
|
|
31748
|
+
params.push(opts.status);
|
|
31749
|
+
where.push(`status = $${params.length}`);
|
|
31750
|
+
}
|
|
31751
|
+
if (opts.provider_company_id) {
|
|
31752
|
+
params.push(opts.provider_company_id);
|
|
31753
|
+
where.push(`provider_company_id = $${params.length}`);
|
|
31754
|
+
}
|
|
31755
|
+
if (opts.applicant_contact_id) {
|
|
31756
|
+
params.push(opts.applicant_contact_id);
|
|
31757
|
+
where.push(`applicant_contact_id = $${params.length}`);
|
|
31758
|
+
}
|
|
31759
|
+
const sql = `SELECT * FROM applications ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
31760
|
+
return (await this.client.many(sql, params)).map((r) => this.mapApplication(r));
|
|
31761
|
+
}
|
|
31762
|
+
async updateApplication(id, input) {
|
|
31763
|
+
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"];
|
|
31764
|
+
const sets = [];
|
|
31765
|
+
const params = [id];
|
|
31766
|
+
for (const c of cols)
|
|
31767
|
+
if (c in input) {
|
|
31768
|
+
params.push(input[c] ?? null);
|
|
31769
|
+
sets.push(`${c} = $${params.length}`);
|
|
31770
|
+
}
|
|
31771
|
+
if ("metadata" in input) {
|
|
31772
|
+
params.push(JSON.stringify(input.metadata));
|
|
31773
|
+
sets.push(`metadata = $${params.length}`);
|
|
31774
|
+
}
|
|
31775
|
+
sets.push(`updated_at = NOW()`);
|
|
31776
|
+
const row = await this.client.get(`UPDATE applications SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
31777
|
+
return row ? this.mapApplication(row) : null;
|
|
31778
|
+
}
|
|
31779
|
+
async listFollowUpDueApplications() {
|
|
31780
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
31781
|
+
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]);
|
|
31782
|
+
return rows.map((r) => this.mapApplication(r));
|
|
31783
|
+
}
|
|
31784
|
+
mapGroup(r) {
|
|
31785
|
+
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 };
|
|
31786
|
+
}
|
|
31787
|
+
async createGroup(input) {
|
|
31788
|
+
const id = newUuid();
|
|
31789
|
+
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]);
|
|
31790
|
+
return this.getGroup(id);
|
|
31791
|
+
}
|
|
31792
|
+
async getGroup(id) {
|
|
31793
|
+
const row = await this.client.get(`SELECT * FROM groups WHERE id = $1`, [id]);
|
|
31794
|
+
return row ? this.mapGroup(row) : null;
|
|
31795
|
+
}
|
|
31796
|
+
async listGroups(projectId) {
|
|
31797
|
+
const params = [];
|
|
31798
|
+
let where = "";
|
|
31799
|
+
if (projectId) {
|
|
31800
|
+
params.push(projectId);
|
|
31801
|
+
where = `WHERE g.project_id = $1`;
|
|
31802
|
+
}
|
|
31803
|
+
const rows = await this.client.many(`SELECT g.*, (SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) AS member_count,
|
|
31804
|
+
(SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) AS company_count
|
|
31805
|
+
FROM groups g ${where} ORDER BY g.name`, params);
|
|
31806
|
+
return rows.map((r) => this.mapGroup(r));
|
|
31807
|
+
}
|
|
31808
|
+
async updateGroup(id, input) {
|
|
31809
|
+
const sets = [];
|
|
31810
|
+
const params = [id];
|
|
31811
|
+
for (const c of ["name", "description", "project_id"])
|
|
31812
|
+
if (c in input) {
|
|
31813
|
+
params.push(input[c] ?? null);
|
|
31814
|
+
sets.push(`${c} = $${params.length}`);
|
|
31815
|
+
}
|
|
31816
|
+
sets.push(`updated_at = NOW()`);
|
|
31817
|
+
await this.client.execute(`UPDATE groups SET ${sets.join(", ")} WHERE id = $1`, params);
|
|
31818
|
+
return this.getGroup(id);
|
|
31819
|
+
}
|
|
31820
|
+
async deleteGroup(id) {
|
|
31821
|
+
return (await this.client.query(`DELETE FROM groups WHERE id = $1`, [id])).rowCount > 0;
|
|
31822
|
+
}
|
|
31823
|
+
async addContactToGroup(contactId, groupId) {
|
|
31824
|
+
const existing = await this.client.get(`SELECT 1 FROM contact_groups WHERE contact_id = $1 AND group_id = $2`, [contactId, groupId]);
|
|
31825
|
+
if (existing)
|
|
31826
|
+
return { added: false, already_member: true };
|
|
31827
|
+
await this.client.execute(`INSERT INTO contact_groups (contact_id, group_id) VALUES ($1,$2)`, [contactId, groupId]);
|
|
31828
|
+
return { added: true, already_member: false };
|
|
31829
|
+
}
|
|
31830
|
+
async removeContactFromGroup(contactId, groupId) {
|
|
31831
|
+
await this.client.execute(`DELETE FROM contact_groups WHERE contact_id = $1 AND group_id = $2`, [contactId, groupId]);
|
|
31832
|
+
}
|
|
31833
|
+
async listContactsInGroup(groupId) {
|
|
31834
|
+
return (await this.client.many(`SELECT contact_id FROM contact_groups WHERE group_id = $1`, [groupId])).map((r) => r.contact_id);
|
|
31835
|
+
}
|
|
31836
|
+
async listGroupsForContact(contactId) {
|
|
31837
|
+
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]);
|
|
31838
|
+
return rows.map((r) => this.mapGroup(r));
|
|
31839
|
+
}
|
|
31840
|
+
async addCompanyToGroup(companyId, groupId) {
|
|
31841
|
+
const existing = await this.client.get(`SELECT 1 FROM company_groups WHERE company_id = $1 AND group_id = $2`, [companyId, groupId]);
|
|
31842
|
+
if (existing)
|
|
31843
|
+
return { added: false, already_member: true };
|
|
31844
|
+
await this.client.execute(`INSERT INTO company_groups (company_id, group_id) VALUES ($1,$2)`, [companyId, groupId]);
|
|
31845
|
+
return { added: true, already_member: false };
|
|
31846
|
+
}
|
|
31847
|
+
async removeCompanyFromGroup(companyId, groupId) {
|
|
31848
|
+
await this.client.execute(`DELETE FROM company_groups WHERE company_id = $1 AND group_id = $2`, [companyId, groupId]);
|
|
31849
|
+
}
|
|
31850
|
+
async listCompaniesInGroup(groupId) {
|
|
31851
|
+
return (await this.client.many(`SELECT company_id FROM company_groups WHERE group_id = $1`, [groupId])).map((r) => r.company_id);
|
|
31852
|
+
}
|
|
31853
|
+
async listGroupsForCompany(companyId) {
|
|
31854
|
+
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]);
|
|
31855
|
+
return rows.map((r) => this.mapGroup(r));
|
|
31856
|
+
}
|
|
31857
|
+
mapVendorComm(r) {
|
|
31858
|
+
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) };
|
|
31859
|
+
}
|
|
31860
|
+
async logVendorCommunication(input) {
|
|
31861
|
+
const id = newUuid();
|
|
31862
|
+
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)
|
|
31863
|
+
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)]);
|
|
31864
|
+
return this.mapVendorComm(row);
|
|
31865
|
+
}
|
|
31866
|
+
async listVendorCommunications(companyId, opts = {}) {
|
|
31867
|
+
const where = ["company_id = $1"];
|
|
31868
|
+
const params = [companyId];
|
|
31869
|
+
if (opts.type) {
|
|
31870
|
+
params.push(opts.type);
|
|
31871
|
+
where.push(`type = $${params.length}`);
|
|
31872
|
+
}
|
|
31873
|
+
if (opts.status) {
|
|
31874
|
+
params.push(opts.status);
|
|
31875
|
+
where.push(`status = $${params.length}`);
|
|
31876
|
+
}
|
|
31877
|
+
if (opts.direction) {
|
|
31878
|
+
params.push(opts.direction);
|
|
31879
|
+
where.push(`direction = $${params.length}`);
|
|
31880
|
+
}
|
|
31881
|
+
const rows = await this.client.many(`SELECT * FROM vendor_communications WHERE ${where.join(" AND ")} ORDER BY comm_date DESC`, params);
|
|
31882
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
31883
|
+
}
|
|
31884
|
+
async listMissingInvoices() {
|
|
31885
|
+
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`);
|
|
31886
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
31887
|
+
}
|
|
31888
|
+
async listPendingFollowUps() {
|
|
31889
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
31890
|
+
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]);
|
|
31891
|
+
return rows.map((r) => this.mapVendorComm(r));
|
|
31892
|
+
}
|
|
31893
|
+
async markFollowUpDone(id) {
|
|
31894
|
+
const row = await this.client.get(`UPDATE vendor_communications SET follow_up_done = true WHERE id = $1 RETURNING *`, [id]);
|
|
31895
|
+
return row ? this.mapVendorComm(row) : null;
|
|
31896
|
+
}
|
|
31897
|
+
mapOrgMember(r) {
|
|
31898
|
+
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) };
|
|
31899
|
+
}
|
|
31900
|
+
async addOrgMember(input) {
|
|
31901
|
+
const id = newUuid();
|
|
31902
|
+
const row = await this.client.get(`INSERT INTO org_members (id, company_id, contact_id, title, specialization, office_phone, response_sla_hours, notes)
|
|
31903
|
+
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]);
|
|
31904
|
+
return this.mapOrgMember(row);
|
|
31905
|
+
}
|
|
31906
|
+
async listOrgMembers(companyId) {
|
|
31907
|
+
return (await this.client.many(`SELECT * FROM org_members WHERE company_id = $1 ORDER BY created_at ASC`, [companyId])).map((r) => this.mapOrgMember(r));
|
|
31908
|
+
}
|
|
31909
|
+
async updateOrgMember(id, input) {
|
|
31910
|
+
const sets = [];
|
|
31911
|
+
const params = [id];
|
|
31912
|
+
for (const c of ["title", "specialization", "office_phone", "response_sla_hours", "notes"])
|
|
31913
|
+
if (c in input) {
|
|
31914
|
+
params.push(input[c] ?? null);
|
|
31915
|
+
sets.push(`${c} = $${params.length}`);
|
|
31916
|
+
}
|
|
31917
|
+
sets.push(`updated_at = NOW()`);
|
|
31918
|
+
const row = await this.client.get(`UPDATE org_members SET ${sets.join(", ")} WHERE id = $1 RETURNING *`, params);
|
|
31919
|
+
return row ? this.mapOrgMember(row) : null;
|
|
31920
|
+
}
|
|
31921
|
+
async removeOrgMember(id) {
|
|
31922
|
+
return (await this.client.query(`DELETE FROM org_members WHERE id = $1`, [id])).rowCount > 0;
|
|
31923
|
+
}
|
|
31924
|
+
async listOrgMembersForContact(contactId) {
|
|
31925
|
+
return (await this.client.many(`SELECT * FROM org_members WHERE contact_id = $1 ORDER BY created_at ASC`, [contactId])).map((r) => this.mapOrgMember(r));
|
|
31926
|
+
}
|
|
31927
|
+
mapNote(r) {
|
|
31928
|
+
return { ...r, created_at: isoOrNull(r.created_at) };
|
|
31929
|
+
}
|
|
31930
|
+
async addNote(contactId, body, createdBy, companyId) {
|
|
31931
|
+
const contact = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [contactId]);
|
|
31932
|
+
if (!contact)
|
|
31933
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
31934
|
+
const id = newUuid();
|
|
31935
|
+
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]);
|
|
31936
|
+
return this.mapNote(row);
|
|
31937
|
+
}
|
|
31938
|
+
async listNotes(contactId) {
|
|
31939
|
+
return (await this.client.many(`SELECT * FROM contact_notes WHERE contact_id = $1 ORDER BY created_at ASC`, [contactId])).map((r) => this.mapNote(r));
|
|
31940
|
+
}
|
|
31941
|
+
async listNotesForContactAtCompany(contactId, companyId) {
|
|
31942
|
+
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));
|
|
31943
|
+
}
|
|
31944
|
+
async deleteNote(noteId) {
|
|
31945
|
+
await this.client.execute(`DELETE FROM contact_notes WHERE id = $1`, [noteId]);
|
|
31946
|
+
}
|
|
31947
|
+
async createRelationship(input) {
|
|
31948
|
+
const id = newUuid();
|
|
31949
|
+
const row = await this.client.get(`INSERT INTO contact_relationships (id, contact_a_id, contact_b_id, relationship_type, notes)
|
|
31950
|
+
VALUES ($1,$2,$3,$4,$5) RETURNING *`, [id, input.contact_a_id, input.contact_b_id, input.relationship_type ?? "knows", input.notes ?? null]);
|
|
31951
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
31952
|
+
}
|
|
31953
|
+
async listRelationships(opts = {}) {
|
|
31954
|
+
if (opts.contact_id) {
|
|
31955
|
+
return this.client.many(`SELECT * FROM contact_relationships WHERE contact_a_id = $1 OR contact_b_id = $1`, [opts.contact_id]);
|
|
31956
|
+
}
|
|
31957
|
+
return this.client.many(`SELECT * FROM contact_relationships`);
|
|
31958
|
+
}
|
|
31959
|
+
async deleteRelationship(id) {
|
|
31960
|
+
await this.client.execute(`DELETE FROM contact_relationships WHERE id = $1`, [id]);
|
|
31961
|
+
}
|
|
31962
|
+
async createCompanyRelationship(input) {
|
|
31963
|
+
const id = newUuid();
|
|
31964
|
+
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)
|
|
31965
|
+
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"]);
|
|
31966
|
+
return { ...row, created_at: isoOrNull(row?.created_at), is_primary: Boolean(row?.is_primary) };
|
|
31967
|
+
}
|
|
31968
|
+
async listCompanyRelationships(opts = {}) {
|
|
31969
|
+
const where = [];
|
|
31970
|
+
const params = [];
|
|
31971
|
+
if (opts.contact_id) {
|
|
31972
|
+
params.push(opts.contact_id);
|
|
31973
|
+
where.push(`contact_id = $${params.length}`);
|
|
31974
|
+
}
|
|
31975
|
+
if (opts.company_id) {
|
|
31976
|
+
params.push(opts.company_id);
|
|
31977
|
+
where.push(`company_id = $${params.length}`);
|
|
31978
|
+
}
|
|
31979
|
+
const sql = `SELECT * FROM company_relationships ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC`;
|
|
31980
|
+
return (await this.client.many(sql, params)).map((r) => ({ ...r, created_at: isoOrNull(r.created_at), is_primary: Boolean(r.is_primary) }));
|
|
31981
|
+
}
|
|
31982
|
+
async deleteCompanyRelationship(id) {
|
|
31983
|
+
await this.client.execute(`DELETE FROM company_relationships WHERE id = $1`, [id]);
|
|
31984
|
+
}
|
|
31985
|
+
async getFieldHistory(contactId, fieldName) {
|
|
31986
|
+
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]);
|
|
31987
|
+
return rows.map((r) => ({ ...r, valid_from: isoOrNull(r.valid_from), created_at: isoOrNull(r.created_at) }));
|
|
31988
|
+
}
|
|
31989
|
+
async getContactAt(contactId, timestamp) {
|
|
31990
|
+
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]);
|
|
31991
|
+
const result = {};
|
|
31992
|
+
for (const r of rows)
|
|
31993
|
+
if (r.new_value != null)
|
|
31994
|
+
result[r.field_name] = r.new_value;
|
|
31995
|
+
return result;
|
|
31996
|
+
}
|
|
31997
|
+
mapJob(r) {
|
|
31998
|
+
return { ...r, is_current: Boolean(r.is_current), inferred: Boolean(r.inferred), created_at: isoOrNull(r.created_at) };
|
|
31999
|
+
}
|
|
32000
|
+
async addJobEntry(contactId, input) {
|
|
32001
|
+
if (input.is_current) {
|
|
32002
|
+
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]);
|
|
32003
|
+
}
|
|
32004
|
+
const id = newUuid();
|
|
32005
|
+
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)
|
|
32006
|
+
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]);
|
|
32007
|
+
return this.mapJob(row);
|
|
32008
|
+
}
|
|
32009
|
+
async getJobHistory(contactId) {
|
|
32010
|
+
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));
|
|
32011
|
+
}
|
|
32012
|
+
mapLearning(r) {
|
|
32013
|
+
return { ...r, tags: pj(r.tags, []), created_at: isoOrNull(r.created_at), updated_at: isoOrNull(r.updated_at) };
|
|
32014
|
+
}
|
|
32015
|
+
async saveLearning(contactId, input) {
|
|
32016
|
+
const id = newUuid();
|
|
32017
|
+
const row = await this.client.get(`INSERT INTO contact_learnings (id, contact_id, content, type, confidence, importance, learned_by, session_id, visibility, tags)
|
|
32018
|
+
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 ?? [])]);
|
|
32019
|
+
return this.mapLearning(row);
|
|
32020
|
+
}
|
|
32021
|
+
async getLearnings(contactId, opts = {}) {
|
|
32022
|
+
let sql = `SELECT * FROM contact_learnings WHERE contact_id = $1`;
|
|
32023
|
+
const params = [contactId];
|
|
32024
|
+
if (opts.type) {
|
|
32025
|
+
params.push(opts.type);
|
|
32026
|
+
sql += ` AND type = $${params.length}`;
|
|
32027
|
+
}
|
|
32028
|
+
if (opts.min_importance) {
|
|
32029
|
+
params.push(opts.min_importance);
|
|
32030
|
+
sql += ` AND importance >= $${params.length}`;
|
|
32031
|
+
}
|
|
32032
|
+
if (opts.visibility) {
|
|
32033
|
+
params.push(opts.visibility);
|
|
32034
|
+
sql += ` AND visibility = $${params.length}`;
|
|
32035
|
+
}
|
|
32036
|
+
sql += ` ORDER BY importance DESC, confidence DESC`;
|
|
32037
|
+
return (await this.client.many(sql, params)).map((r) => this.mapLearning(r));
|
|
32038
|
+
}
|
|
32039
|
+
async searchLearnings(query, opts = {}) {
|
|
32040
|
+
let sql = `SELECT * FROM contact_learnings WHERE content ILIKE $1`;
|
|
32041
|
+
const params = [`%${query}%`];
|
|
32042
|
+
if (opts.type) {
|
|
32043
|
+
params.push(opts.type);
|
|
32044
|
+
sql += ` AND type = $${params.length}`;
|
|
32045
|
+
}
|
|
32046
|
+
if (opts.contact_id) {
|
|
32047
|
+
params.push(opts.contact_id);
|
|
32048
|
+
sql += ` AND contact_id = $${params.length}`;
|
|
32049
|
+
}
|
|
32050
|
+
sql += ` ORDER BY importance DESC, confidence DESC LIMIT 50`;
|
|
32051
|
+
return (await this.client.many(sql, params)).map((r) => this.mapLearning(r));
|
|
32052
|
+
}
|
|
32053
|
+
async confirmLearning(learningId) {
|
|
32054
|
+
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]);
|
|
32055
|
+
}
|
|
32056
|
+
async getStaleLearnings(daysOld, minConfidence) {
|
|
32057
|
+
const cutoff = new Date(Date.now() - daysOld * 86400000).toISOString();
|
|
32058
|
+
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));
|
|
32059
|
+
}
|
|
32060
|
+
async runLearningMaintenance() {
|
|
32061
|
+
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
32062
|
+
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]);
|
|
32063
|
+
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`);
|
|
32064
|
+
return { decayed_count: res.rowCount, potential_contradictions: dups };
|
|
32065
|
+
}
|
|
32066
|
+
mapLock(r) {
|
|
32067
|
+
return { ...r, acquired_at: isoOrNull(r.acquired_at), expires_at: isoOrNull(r.expires_at) };
|
|
32068
|
+
}
|
|
32069
|
+
async acquireContactLock(contactId, agentName, ttlSeconds = 300, reason, sessionId) {
|
|
32070
|
+
await this.client.execute(`DELETE FROM contact_locks WHERE expires_at < NOW()`);
|
|
32071
|
+
const existing = await this.client.get(`SELECT * FROM contact_locks WHERE contact_id = $1`, [contactId]);
|
|
32072
|
+
if (existing)
|
|
32073
|
+
return { acquired: false, held_by: existing.agent_name, lock: this.mapLock(existing) };
|
|
32074
|
+
const id = newUuid();
|
|
32075
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
32076
|
+
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]);
|
|
32077
|
+
return { acquired: true, lock: this.mapLock(row) };
|
|
32078
|
+
}
|
|
32079
|
+
async releaseContactLock(contactId, agentName) {
|
|
32080
|
+
return (await this.client.query(`DELETE FROM contact_locks WHERE contact_id = $1 AND agent_name = $2`, [contactId, agentName])).rowCount > 0;
|
|
32081
|
+
}
|
|
32082
|
+
async checkContactLock(contactId) {
|
|
32083
|
+
await this.client.execute(`DELETE FROM contact_locks WHERE expires_at < NOW()`);
|
|
32084
|
+
const row = await this.client.get(`SELECT * FROM contact_locks WHERE contact_id = $1`, [contactId]);
|
|
32085
|
+
return row ? this.mapLock(row) : null;
|
|
32086
|
+
}
|
|
32087
|
+
async logAgentActivity(contactId, agentName, action, details, sessionId) {
|
|
32088
|
+
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]);
|
|
32089
|
+
}
|
|
32090
|
+
async getAgentActivity(contactId, limit = 20) {
|
|
32091
|
+
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) }));
|
|
32092
|
+
}
|
|
32093
|
+
async resolveContactIdentity(partial2) {
|
|
32094
|
+
const matches = new Map;
|
|
32095
|
+
const add = (id, name, title, score, reason) => {
|
|
32096
|
+
const ex = matches.get(id);
|
|
32097
|
+
if (ex) {
|
|
32098
|
+
ex.confidence_score = Math.min(100, ex.confidence_score + score);
|
|
32099
|
+
ex.match_reasons.push(reason);
|
|
32100
|
+
} else
|
|
32101
|
+
matches.set(id, { contact: { id, display_name: name, job_title: title }, confidence_score: score, match_reasons: [reason] });
|
|
32102
|
+
};
|
|
32103
|
+
if (partial2.email) {
|
|
32104
|
+
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)`, [partial2.email]);
|
|
32105
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 90, `email match: ${partial2.email}`));
|
|
32106
|
+
}
|
|
32107
|
+
if (partial2.linkedin_url) {
|
|
32108
|
+
const tail = partial2.linkedin_url.split("/").pop();
|
|
32109
|
+
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}%`]);
|
|
32110
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 85, `linkedin match`));
|
|
32111
|
+
}
|
|
32112
|
+
if (partial2.name) {
|
|
32113
|
+
const rows = await this.client.many(`SELECT id, display_name, job_title FROM contacts WHERE display_name ILIKE $1 AND archived = false LIMIT 10`, [`%${partial2.name}%`]);
|
|
32114
|
+
rows.forEach((r) => add(r.id, r.display_name, r.job_title, 40, `name match: ${partial2.name}`));
|
|
32115
|
+
}
|
|
32116
|
+
return Array.from(matches.values()).sort((a, b) => b.confidence_score - a.confidence_score);
|
|
32117
|
+
}
|
|
32118
|
+
async addContactIdentity(contactId, system, externalId, externalUrl, confidence = "inferred") {
|
|
32119
|
+
const id = newUuid();
|
|
32120
|
+
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)
|
|
32121
|
+
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]);
|
|
32122
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
32123
|
+
}
|
|
32124
|
+
async getContactIdentities(contactId) {
|
|
32125
|
+
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) }));
|
|
32126
|
+
}
|
|
32127
|
+
signalRow(r) {
|
|
32128
|
+
const last = r.last_contacted_at;
|
|
32129
|
+
return {
|
|
32130
|
+
contact_id: r.contact_id,
|
|
32131
|
+
display_name: r.display_name,
|
|
32132
|
+
last_contacted_at: last,
|
|
32133
|
+
interaction_count_30d: Number(r.interaction_count_30d ?? 0),
|
|
32134
|
+
engagement_status: r.engagement_status ?? null,
|
|
32135
|
+
relationship_health: r.relationship_health ?? null,
|
|
32136
|
+
days_since_contact: last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null
|
|
32137
|
+
};
|
|
32138
|
+
}
|
|
32139
|
+
async getRelationshipSignals(contactId) {
|
|
32140
|
+
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]);
|
|
32141
|
+
if (!row)
|
|
32142
|
+
return [];
|
|
32143
|
+
const base = this.signalRow(row);
|
|
32144
|
+
const cnt = base.interaction_count_30d;
|
|
32145
|
+
const health = base.relationship_health ?? 50;
|
|
32146
|
+
const daysSince = base.days_since_contact;
|
|
32147
|
+
let signal_type = "healthy";
|
|
32148
|
+
let reason = `Last contact ${daysSince}d ago, ${cnt} interactions in 30d`;
|
|
32149
|
+
if (daysSince === null || daysSince > 180) {
|
|
32150
|
+
signal_type = "ghost";
|
|
32151
|
+
reason = "No contact in 180+ days or never contacted";
|
|
32152
|
+
} else if (daysSince > 60 && cnt === 0) {
|
|
32153
|
+
signal_type = "cooling";
|
|
32154
|
+
reason = `No contact in ${daysSince} days, no recent interactions`;
|
|
32155
|
+
} else if (cnt > 3 && health > 70) {
|
|
32156
|
+
signal_type = "warming";
|
|
32157
|
+
reason = `${cnt} interactions in last 30 days, health score ${health}`;
|
|
32158
|
+
}
|
|
32159
|
+
return [{ ...base, signal_type, reason }];
|
|
32160
|
+
}
|
|
32161
|
+
async getGhostContacts() {
|
|
32162
|
+
const cutoff = new Date(Date.now() - 180 * 86400000).toISOString();
|
|
32163
|
+
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]);
|
|
32164
|
+
return rows.map((r) => ({ ...this.signalRow(r), signal_type: "ghost", reason: "No contact in 180+ days or never contacted" }));
|
|
32165
|
+
}
|
|
32166
|
+
async getWarmingContacts() {
|
|
32167
|
+
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`);
|
|
32168
|
+
return rows.map((r) => ({ ...this.signalRow(r), signal_type: "warming", reason: `${Number(r.interaction_count_30d ?? 0)} interactions in last 30 days` }));
|
|
32169
|
+
}
|
|
32170
|
+
async recomputeSignals() {
|
|
32171
|
+
const res = await this.client.query(`UPDATE contacts SET engagement_status = CASE
|
|
32172
|
+
WHEN interaction_count_30d > 3 THEN 'warming'
|
|
32173
|
+
WHEN last_contacted_at IS NULL OR EXTRACT(EPOCH FROM (NOW() - last_contacted_at::timestamptz)) / 86400 > 180 THEN 'ghost'
|
|
32174
|
+
WHEN EXTRACT(EPOCH FROM (NOW() - last_contacted_at::timestamptz)) / 86400 > 60 THEN 'cooling'
|
|
32175
|
+
ELSE 'stable' END,
|
|
32176
|
+
updated_at = NOW() WHERE archived = false`);
|
|
32177
|
+
return { updated: res.rowCount };
|
|
32178
|
+
}
|
|
32179
|
+
async getFreshnessScore(contactId) {
|
|
32180
|
+
const contact = await this.client.get(`SELECT * FROM contacts WHERE id = $1`, [contactId]);
|
|
32181
|
+
if (!contact)
|
|
32182
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
32183
|
+
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]);
|
|
32184
|
+
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(() => []);
|
|
32185
|
+
const scored = ["display_name", "job_title", "company_id", "emails", "phones", "last_contacted_at"];
|
|
32186
|
+
const verifiedMap = new Map(verifiedRows.map((r) => [r.field_name, r]));
|
|
32187
|
+
const historyMap = new Map;
|
|
32188
|
+
for (const r of historyRows)
|
|
32189
|
+
if (!historyMap.has(r.field_name))
|
|
32190
|
+
historyMap.set(r.field_name, r);
|
|
32191
|
+
const fields = [];
|
|
32192
|
+
for (const field of scored) {
|
|
32193
|
+
let value = null;
|
|
32194
|
+
if (field === "emails") {
|
|
32195
|
+
const e = await this.client.get(`SELECT address FROM emails WHERE contact_id = $1 LIMIT 1`, [contactId]);
|
|
32196
|
+
value = e?.address ?? null;
|
|
32197
|
+
} else if (field === "phones") {
|
|
32198
|
+
const p = await this.client.get(`SELECT number FROM phones WHERE contact_id = $1 LIMIT 1`, [contactId]);
|
|
32199
|
+
value = p?.number ?? null;
|
|
32200
|
+
} else
|
|
32201
|
+
value = contact[field] != null ? String(contact[field]) : null;
|
|
32202
|
+
const verified = verifiedMap.get(field);
|
|
32203
|
+
const history = historyMap.get(field);
|
|
32204
|
+
let confidence = "unknown";
|
|
32205
|
+
let days_old = null;
|
|
32206
|
+
let last_verified_at = null;
|
|
32207
|
+
let source = null;
|
|
32208
|
+
if (verified) {
|
|
32209
|
+
confidence = "verified";
|
|
32210
|
+
last_verified_at = isoOrNull(verified.last_verified_at);
|
|
32211
|
+
source = verified.source;
|
|
32212
|
+
days_old = last_verified_at ? Math.floor((Date.now() - new Date(last_verified_at).getTime()) / 86400000) : null;
|
|
32213
|
+
} else if (history) {
|
|
32214
|
+
confidence = history.source === "import" ? "imported" : "inferred";
|
|
32215
|
+
last_verified_at = isoOrNull(history.created_at);
|
|
32216
|
+
source = history.source;
|
|
32217
|
+
days_old = last_verified_at ? Math.floor((Date.now() - new Date(last_verified_at).getTime()) / 86400000) : null;
|
|
32218
|
+
if (days_old != null && days_old > 365)
|
|
32219
|
+
confidence = "stale";
|
|
32220
|
+
} else if (value)
|
|
32221
|
+
confidence = "inferred";
|
|
32222
|
+
fields.push({ field_name: field, value, last_verified_at, source, confidence, days_old });
|
|
32223
|
+
}
|
|
32224
|
+
const fieldScore = fields.reduce((acc, f) => {
|
|
32225
|
+
if (!f.value)
|
|
32226
|
+
return acc;
|
|
32227
|
+
if (f.confidence === "verified")
|
|
32228
|
+
return acc + 20;
|
|
32229
|
+
if (f.confidence === "imported" || f.confidence === "inferred")
|
|
32230
|
+
return acc + 10;
|
|
32231
|
+
return acc + 5;
|
|
32232
|
+
}, 0);
|
|
32233
|
+
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) };
|
|
32234
|
+
}
|
|
32235
|
+
async getStaleContacts(threshold = 40) {
|
|
32236
|
+
return this.client.many(`SELECT * FROM (
|
|
32237
|
+
SELECT c.id AS contact_id, c.display_name,
|
|
32238
|
+
((CASE WHEN c.job_title IS NOT NULL THEN 15 ELSE 0 END) +
|
|
32239
|
+
(CASE WHEN c.company_id IS NOT NULL THEN 15 ELSE 0 END) +
|
|
32240
|
+
(CASE WHEN c.last_contacted_at IS NOT NULL THEN 20 ELSE 0 END) +
|
|
32241
|
+
(CASE WHEN EXISTS(SELECT 1 FROM emails WHERE contact_id = c.id) THEN 20 ELSE 0 END) +
|
|
32242
|
+
(CASE WHEN EXISTS(SELECT 1 FROM phones WHERE contact_id = c.id) THEN 15 ELSE 0 END) +
|
|
32243
|
+
(CASE WHEN c.notes IS NOT NULL THEN 10 ELSE 0 END) +
|
|
32244
|
+
(CASE WHEN EXISTS(SELECT 1 FROM contact_tags WHERE contact_id = c.id) THEN 5 ELSE 0 END)) AS score
|
|
32245
|
+
FROM contacts c WHERE c.archived = false
|
|
32246
|
+
) sub WHERE score < $1 ORDER BY score ASC LIMIT 100`, [threshold]);
|
|
32247
|
+
}
|
|
32248
|
+
async markFieldVerified(contactId, fieldName, source) {
|
|
32249
|
+
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())
|
|
32250
|
+
ON CONFLICT (contact_id, field_name) DO UPDATE SET confidence = 'verified', source = excluded.source, last_verified_at = NOW()`, [newUuid(), contactId, fieldName, source ?? null]);
|
|
32251
|
+
}
|
|
32252
|
+
async computeRelationshipStrength(contactId) {
|
|
32253
|
+
const c = await this.client.get(`SELECT last_contacted_at, interaction_count_30d FROM contacts WHERE id = $1`, [contactId]);
|
|
32254
|
+
if (!c)
|
|
32255
|
+
return 0;
|
|
32256
|
+
let score = 50;
|
|
32257
|
+
if (c.last_contacted_at) {
|
|
32258
|
+
const days = Math.floor((Date.now() - new Date(c.last_contacted_at).getTime()) / 86400000);
|
|
32259
|
+
score += days < 7 ? 30 : days < 30 ? 20 : days < 90 ? 5 : -20;
|
|
32260
|
+
} else
|
|
32261
|
+
score -= 20;
|
|
32262
|
+
score += Math.min(20, (c.interaction_count_30d || 0) * 4);
|
|
32263
|
+
return Math.max(0, Math.min(100, score));
|
|
32264
|
+
}
|
|
32265
|
+
async findWarmPath(fromContactId, toContactId) {
|
|
32266
|
+
const visited = new Set([fromContactId]);
|
|
32267
|
+
const queue = [{ id: fromContactId, path: [] }];
|
|
32268
|
+
while (queue.length) {
|
|
32269
|
+
const { id, path } = queue.shift();
|
|
32270
|
+
if (id === toContactId)
|
|
32271
|
+
return path;
|
|
32272
|
+
if (path.length >= 4)
|
|
32273
|
+
continue;
|
|
32274
|
+
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]);
|
|
32275
|
+
for (const n of neighbors) {
|
|
32276
|
+
const nextId = n.contact_a_id === id ? n.contact_b_id : n.contact_a_id;
|
|
32277
|
+
if (visited.has(nextId))
|
|
32278
|
+
continue;
|
|
32279
|
+
visited.add(nextId);
|
|
32280
|
+
queue.push({ id: nextId, path: [...path, { contact_id: nextId, display_name: n.display_name, strength: n.strength_score || 50 }] });
|
|
32281
|
+
}
|
|
32282
|
+
}
|
|
32283
|
+
return [];
|
|
32284
|
+
}
|
|
32285
|
+
async findConnectionsAtCompany(companyId) {
|
|
32286
|
+
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]);
|
|
32287
|
+
}
|
|
32288
|
+
async detectCoolingRelationships() {
|
|
32289
|
+
const cutoff = new Date(Date.now() - 45 * 86400000).toISOString();
|
|
32290
|
+
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]);
|
|
32291
|
+
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) }));
|
|
32292
|
+
}
|
|
32293
|
+
async addOrgChartEdge(companyId, contactAId, contactBId, edgeType, inferred = false) {
|
|
32294
|
+
const id = newUuid();
|
|
32295
|
+
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)
|
|
32296
|
+
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]);
|
|
32297
|
+
return { ...row, inferred: Boolean(row?.inferred), created_at: isoOrNull(row?.created_at) };
|
|
32298
|
+
}
|
|
32299
|
+
async listOrgChart(companyId) {
|
|
32300
|
+
return this.client.many(`SELECT ca.display_name AS contact_a_name, cb.display_name AS contact_b_name, e.edge_type
|
|
32301
|
+
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]);
|
|
32302
|
+
}
|
|
32303
|
+
async setDealContactRole(dealId, contactId, accountRole) {
|
|
32304
|
+
const id = newUuid();
|
|
32305
|
+
const row = await this.client.get(`INSERT INTO deal_contact_roles (id, deal_id, contact_id, account_role) VALUES ($1,$2,$3,$4)
|
|
32306
|
+
ON CONFLICT (deal_id, contact_id) DO UPDATE SET account_role = excluded.account_role RETURNING *`, [id, dealId, contactId, accountRole]);
|
|
32307
|
+
return { ...row, created_at: isoOrNull(row?.created_at) };
|
|
32308
|
+
}
|
|
32309
|
+
async getDealTeam(dealId) {
|
|
32310
|
+
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]);
|
|
32311
|
+
}
|
|
32312
|
+
async getCoverageGaps(companyId) {
|
|
32313
|
+
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]);
|
|
32314
|
+
const covered = new Set(team.map((t) => t.account_role));
|
|
32315
|
+
const key = ["economic_buyer", "technical_evaluator", "champion"];
|
|
32316
|
+
return { covered: Array.from(covered), missing_key_roles: key.filter((k) => !covered.has(k)) };
|
|
32317
|
+
}
|
|
32318
|
+
mapAudience(r) {
|
|
32319
|
+
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) };
|
|
32320
|
+
}
|
|
32321
|
+
async createAudience(input) {
|
|
32322
|
+
const audienceId = String(input.audience_id ?? "");
|
|
32323
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(audienceId))
|
|
32324
|
+
throw new Error(`audience_id must be a lowercase dashed slug: ${audienceId}`);
|
|
32325
|
+
const predicates = input.predicates ?? [];
|
|
32326
|
+
if (!Array.isArray(predicates) || predicates.length === 0)
|
|
32327
|
+
throw new Error("at least one predicate is required");
|
|
32328
|
+
const dupe = await this.client.get(`SELECT id FROM audiences WHERE audience_id = $1`, [audienceId]);
|
|
32329
|
+
if (dupe)
|
|
32330
|
+
throw new Error(`duplicate audience_id: ${audienceId}`);
|
|
32331
|
+
const id = newUuid();
|
|
32332
|
+
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"]);
|
|
32333
|
+
const mapped = this.mapAudience(row);
|
|
32334
|
+
return { ...mapped, id: mapped.id, audience_id: mapped.audience_id };
|
|
32335
|
+
}
|
|
32336
|
+
async getAudience(idOrSlug) {
|
|
32337
|
+
const row = await this.client.get(`SELECT * FROM audiences WHERE id = $1 OR audience_id = $1`, [idOrSlug]);
|
|
32338
|
+
if (!row)
|
|
32339
|
+
throw new Error(`audience not found: ${idOrSlug}`);
|
|
32340
|
+
return this.mapAudience(row);
|
|
32341
|
+
}
|
|
32342
|
+
async listAudiences() {
|
|
32343
|
+
return (await this.client.many(`SELECT * FROM audiences ORDER BY audience_id ASC`)).map((r) => this.mapAudience(r));
|
|
32344
|
+
}
|
|
32345
|
+
async updateAudience(idOrSlug, input) {
|
|
32346
|
+
const audience = await this.getAudience(idOrSlug);
|
|
32347
|
+
const sets = [];
|
|
32348
|
+
const params = [audience.id];
|
|
32349
|
+
if ("name" in input) {
|
|
32350
|
+
params.push(input.name);
|
|
32351
|
+
sets.push(`name = $${params.length}`);
|
|
32352
|
+
}
|
|
32353
|
+
if ("match" in input) {
|
|
32354
|
+
params.push(input.match);
|
|
32355
|
+
sets.push(`match = $${params.length}`);
|
|
32356
|
+
}
|
|
32357
|
+
if ("predicates" in input) {
|
|
32358
|
+
params.push(JSON.stringify(input.predicates));
|
|
32359
|
+
sets.push(`predicates = $${params.length}`);
|
|
32360
|
+
}
|
|
32361
|
+
if ("consent_policy" in input) {
|
|
32362
|
+
params.push(input.consent_policy);
|
|
32363
|
+
sets.push(`consent_policy = $${params.length}`);
|
|
32364
|
+
}
|
|
32365
|
+
if (sets.length) {
|
|
32366
|
+
sets.push(`updated_at = NOW()`);
|
|
32367
|
+
await this.client.execute(`UPDATE audiences SET ${sets.join(", ")} WHERE id = $1`, params);
|
|
32368
|
+
}
|
|
32369
|
+
return this.getAudience(String(audience.id));
|
|
32370
|
+
}
|
|
32371
|
+
async deleteAudience(idOrSlug) {
|
|
32372
|
+
const audience = await this.getAudience(idOrSlug);
|
|
32373
|
+
await this.client.execute(`DELETE FROM audiences WHERE id = $1`, [String(audience.id)]);
|
|
32374
|
+
}
|
|
32375
|
+
async setContactConsent(contactId, channel, status, source) {
|
|
32376
|
+
const contact = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [contactId]);
|
|
32377
|
+
if (!contact)
|
|
32378
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
32379
|
+
const row = await this.client.get(`INSERT INTO contact_consent (contact_id, channel, status, source, updated_at) VALUES ($1,$2,$3,$4,NOW())
|
|
32380
|
+
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]);
|
|
32381
|
+
return { ...row, updated_at: isoOrNull(row?.updated_at) };
|
|
32382
|
+
}
|
|
32383
|
+
async listContactConsent(contactId) {
|
|
32384
|
+
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) }));
|
|
32385
|
+
}
|
|
32386
|
+
async suppressAddress(input) {
|
|
32387
|
+
const id = newUuid();
|
|
32388
|
+
const row = await this.client.get(`INSERT INTO contact_suppressions (id, contact_id, channel, address, reason) VALUES ($1,$2,$3,$4,$5)
|
|
32389
|
+
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]);
|
|
32390
|
+
if (input.contact_id) {
|
|
32391
|
+
const c = await this.client.get(`SELECT id FROM contacts WHERE id = $1`, [input.contact_id]);
|
|
32392
|
+
if (c)
|
|
32393
|
+
await this.setContactConsent(input.contact_id, input.channel, "opt_out", input.reason ?? "suppressed");
|
|
32394
|
+
}
|
|
32395
|
+
return { ...row, created_at: isoOrNull(row?.created_at), synced_at: isoOrNull(row?.synced_at) };
|
|
32396
|
+
}
|
|
32397
|
+
async unsuppressAddress(channel, address) {
|
|
32398
|
+
await this.client.execute(`DELETE FROM contact_suppressions WHERE channel = $1 AND address = $2`, [channel, address]);
|
|
32399
|
+
}
|
|
32400
|
+
async listSuppressions(opts = {}) {
|
|
32401
|
+
const where = [];
|
|
32402
|
+
const params = [];
|
|
32403
|
+
if (opts.channel) {
|
|
32404
|
+
params.push(opts.channel);
|
|
32405
|
+
where.push(`channel = $${params.length}`);
|
|
32406
|
+
}
|
|
32407
|
+
if (opts.unsyncedOnly)
|
|
32408
|
+
where.push(`synced_at IS NULL`);
|
|
32409
|
+
const sql = `SELECT * FROM contact_suppressions ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at ASC`;
|
|
32410
|
+
return (await this.client.many(sql, params)).map((r) => ({ ...r, created_at: isoOrNull(r.created_at), synced_at: isoOrNull(r.synced_at) }));
|
|
32411
|
+
}
|
|
32412
|
+
async resolveAudience(idOrSlug, channel) {
|
|
32413
|
+
const audience = await this.getAudience(idOrSlug);
|
|
32414
|
+
const candidates = await this.client.many(`SELECT * FROM contacts WHERE archived = false`);
|
|
32415
|
+
const predicates = audience.predicates;
|
|
32416
|
+
const norm = (v) => v === null || v === undefined ? null : typeof v === "boolean" ? v ? "true" : "false" : String(v);
|
|
32417
|
+
const compare2 = (actual, p) => {
|
|
32418
|
+
const op = p.op ?? "eq";
|
|
32419
|
+
const a = norm(actual);
|
|
32420
|
+
switch (op) {
|
|
32421
|
+
case "exists":
|
|
32422
|
+
return a !== null && a !== "";
|
|
32423
|
+
case "not_exists":
|
|
32424
|
+
return a === null || a === "";
|
|
32425
|
+
case "eq":
|
|
32426
|
+
return a !== null && a === norm(p.value);
|
|
32427
|
+
case "neq":
|
|
32428
|
+
return a === null || a !== norm(p.value);
|
|
32429
|
+
case "in":
|
|
32430
|
+
return a !== null && (p.values ?? []).some((v) => norm(v) === a);
|
|
32431
|
+
case "not_in":
|
|
32432
|
+
return a === null || !(p.values ?? []).some((v) => norm(v) === a);
|
|
32433
|
+
default:
|
|
32434
|
+
return false;
|
|
32435
|
+
}
|
|
32436
|
+
};
|
|
32437
|
+
const membership = (names, p) => {
|
|
32438
|
+
const op = p.op ?? "eq";
|
|
32439
|
+
const set2 = new Set(names.map((n) => n.toLowerCase()));
|
|
32440
|
+
const has = (v) => {
|
|
32441
|
+
const n = norm(v);
|
|
32442
|
+
return n !== null && set2.has(n.toLowerCase());
|
|
32443
|
+
};
|
|
32444
|
+
switch (op) {
|
|
32445
|
+
case "exists":
|
|
32446
|
+
return set2.size > 0;
|
|
32447
|
+
case "not_exists":
|
|
32448
|
+
return set2.size === 0;
|
|
32449
|
+
case "eq":
|
|
32450
|
+
return has(p.value);
|
|
32451
|
+
case "neq":
|
|
32452
|
+
return !has(p.value);
|
|
32453
|
+
case "in":
|
|
32454
|
+
return (p.values ?? []).some(has);
|
|
32455
|
+
case "not_in":
|
|
32456
|
+
return !(p.values ?? []).some(has);
|
|
32457
|
+
default:
|
|
32458
|
+
return false;
|
|
32459
|
+
}
|
|
32460
|
+
};
|
|
32461
|
+
const matched = [];
|
|
32462
|
+
for (const row of candidates) {
|
|
32463
|
+
const results = [];
|
|
32464
|
+
for (const p of predicates) {
|
|
32465
|
+
if (p.kind === "tag") {
|
|
32466
|
+
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);
|
|
32467
|
+
results.push(membership(names, p));
|
|
32468
|
+
} else if (p.kind === "group") {
|
|
32469
|
+
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]);
|
|
32470
|
+
results.push(membership(rows.flatMap((r) => [r.name, r.id]), p));
|
|
32471
|
+
} else if (p.kind === "attribute") {
|
|
32472
|
+
const key = p.key ?? "";
|
|
32473
|
+
const val = key in row && key !== "custom_fields" ? row[key] : pj(row.custom_fields, {})[key];
|
|
32474
|
+
results.push(compare2(val, p));
|
|
32475
|
+
} else
|
|
32476
|
+
results.push(false);
|
|
32477
|
+
}
|
|
32478
|
+
if (audience.match === "any" ? results.some(Boolean) : results.every(Boolean))
|
|
32479
|
+
matched.push(row);
|
|
32480
|
+
}
|
|
32481
|
+
const suppressed = new Set((await this.client.many(`SELECT address FROM contact_suppressions WHERE channel = $1`, [channel])).map((r) => r.address.toLowerCase()));
|
|
32482
|
+
const recipients = [];
|
|
32483
|
+
const excluded = [];
|
|
32484
|
+
const consentAllows2 = (policy, status) => policy === "opt_in" ? status === "opt_in" : policy === "none" ? true : status !== "opt_out";
|
|
32485
|
+
for (const row of matched) {
|
|
32486
|
+
const cid = row.id;
|
|
32487
|
+
if (row.do_not_contact) {
|
|
32488
|
+
excluded.push({ contact_id: cid, reason: "do_not_contact" });
|
|
32489
|
+
continue;
|
|
32490
|
+
}
|
|
32491
|
+
let address = null;
|
|
32492
|
+
if (channel === "email") {
|
|
32493
|
+
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]);
|
|
32494
|
+
address = e?.address ?? null;
|
|
32495
|
+
} else if (channel === "sms") {
|
|
32496
|
+
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]);
|
|
32497
|
+
address = p?.number ?? null;
|
|
32498
|
+
} else {
|
|
32499
|
+
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]);
|
|
32500
|
+
address = s?.handle ?? s?.url ?? null;
|
|
32501
|
+
}
|
|
32502
|
+
if (!address) {
|
|
32503
|
+
excluded.push({ contact_id: cid, reason: "no_address" });
|
|
32504
|
+
continue;
|
|
32505
|
+
}
|
|
32506
|
+
if (suppressed.has(address.toLowerCase())) {
|
|
32507
|
+
excluded.push({ contact_id: cid, reason: "suppressed" });
|
|
32508
|
+
continue;
|
|
32509
|
+
}
|
|
32510
|
+
const consent = await this.client.get(`SELECT status FROM contact_consent WHERE contact_id = $1 AND channel = $2`, [cid, channel]);
|
|
32511
|
+
const status = consent?.status ?? "unknown";
|
|
32512
|
+
if (!consentAllows2(String(audience.consent_policy), status)) {
|
|
32513
|
+
excluded.push({ contact_id: cid, reason: "consent" });
|
|
32514
|
+
continue;
|
|
32515
|
+
}
|
|
32516
|
+
recipients.push({ contact_id: cid, display_name: row.display_name, address, consent_status: status });
|
|
32517
|
+
}
|
|
32518
|
+
return { audience_id: audience.audience_id, channel, consent_policy: audience.consent_policy, matched: matched.length, recipients, excluded };
|
|
32519
|
+
}
|
|
32520
|
+
async getUpcomingItems(days = 7) {
|
|
32521
|
+
const now3 = new Date;
|
|
32522
|
+
const future = new Date(now3.getTime() + days * 86400000);
|
|
32523
|
+
const todayStr = now3.toISOString().slice(0, 10);
|
|
32524
|
+
const futureStr = future.toISOString().slice(0, 10);
|
|
32525
|
+
const urgency = (d) => d < todayStr ? "overdue" : d === todayStr ? "today" : "upcoming";
|
|
32526
|
+
const items = [];
|
|
32527
|
+
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]))
|
|
32528
|
+
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) });
|
|
32529
|
+
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]))
|
|
32530
|
+
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) });
|
|
32531
|
+
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]))
|
|
32532
|
+
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) });
|
|
32533
|
+
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]))
|
|
32534
|
+
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) });
|
|
32535
|
+
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`)) {
|
|
32536
|
+
const bday = new Date(c.birthday);
|
|
32537
|
+
const thisYear = new Date(now3.getFullYear(), bday.getMonth(), bday.getDate());
|
|
32538
|
+
const nextBday = thisYear >= now3 ? thisYear : new Date(now3.getFullYear() + 1, bday.getMonth(), bday.getDate());
|
|
32539
|
+
const nextStr = nextBday.toISOString().slice(0, 10);
|
|
32540
|
+
if (nextStr <= futureStr)
|
|
32541
|
+
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" });
|
|
32542
|
+
}
|
|
32543
|
+
return items.sort((a, b) => String(a.date).localeCompare(String(b.date)));
|
|
32544
|
+
}
|
|
32545
|
+
async listContactAudit() {
|
|
32546
|
+
const rows = await this.client.many(`SELECT * FROM contacts LIMIT 500`);
|
|
32547
|
+
const results = await Promise.all(rows.map(async (row) => {
|
|
32548
|
+
const details = await this.loadDetails(mapContact(row));
|
|
32549
|
+
const c = details;
|
|
32550
|
+
const missing = [];
|
|
32551
|
+
const suggestions = [];
|
|
32552
|
+
let score = 0;
|
|
32553
|
+
if (c.emails?.length)
|
|
32554
|
+
score += 20;
|
|
32555
|
+
else {
|
|
32556
|
+
missing.push("email");
|
|
32557
|
+
suggestions.push("Add an email address");
|
|
32558
|
+
}
|
|
32559
|
+
if (c.phones?.length)
|
|
32560
|
+
score += 15;
|
|
32561
|
+
else {
|
|
32562
|
+
missing.push("phone");
|
|
32563
|
+
suggestions.push("Add a phone number");
|
|
32564
|
+
}
|
|
32565
|
+
if (c.company_id)
|
|
32566
|
+
score += 15;
|
|
32567
|
+
else {
|
|
32568
|
+
missing.push("company");
|
|
32569
|
+
suggestions.push("Link to a company");
|
|
32570
|
+
}
|
|
32571
|
+
if (c.last_contacted_at)
|
|
32572
|
+
score += 20;
|
|
32573
|
+
else {
|
|
32574
|
+
missing.push("last_contacted_at");
|
|
32575
|
+
suggestions.push("Log a contact interaction");
|
|
32576
|
+
}
|
|
32577
|
+
if (c.tags?.length)
|
|
32578
|
+
score += 10;
|
|
32579
|
+
else {
|
|
32580
|
+
missing.push("tags");
|
|
32581
|
+
suggestions.push("Add at least one tag");
|
|
32582
|
+
}
|
|
32583
|
+
if (c.notes)
|
|
32584
|
+
score += 10;
|
|
32585
|
+
else {
|
|
32586
|
+
missing.push("notes");
|
|
32587
|
+
suggestions.push("Add notes");
|
|
32588
|
+
}
|
|
32589
|
+
if (c.job_title)
|
|
32590
|
+
score += 10;
|
|
32591
|
+
else {
|
|
32592
|
+
missing.push("job_title");
|
|
32593
|
+
suggestions.push("Add a job title");
|
|
32594
|
+
}
|
|
32595
|
+
return { contact_id: c.id, display_name: c.display_name, score, missing, suggestions };
|
|
32596
|
+
}));
|
|
32597
|
+
return results.sort((a, b) => a.score - b.score);
|
|
32598
|
+
}
|
|
32599
|
+
async getContactTimeline(contactId, limit = 50) {
|
|
32600
|
+
const items = [];
|
|
32601
|
+
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]))
|
|
32602
|
+
items.push({ date: iso(n.created_at), type: "note", title: "Note", body: n.body });
|
|
32603
|
+
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}%`]))
|
|
32604
|
+
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 } });
|
|
32605
|
+
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])) {
|
|
32606
|
+
items.push({ date: iso(t.created_at), type: "task_created", title: `Task created: ${t.title}`, metadata: { deadline: t.deadline, priority: t.priority } });
|
|
32607
|
+
if (t.status === "completed")
|
|
32608
|
+
items.push({ date: iso(t.updated_at), type: "task_completed", title: `Task completed: ${t.title}` });
|
|
32609
|
+
}
|
|
32610
|
+
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]))
|
|
32611
|
+
items.push({ date: c.comm_date, type: "vendor_comm", title: `${c.type} \u2014 ${c.company_name}`, body: c.subject ?? undefined });
|
|
32612
|
+
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]))
|
|
32613
|
+
items.push({ date: iso(a.created_at), type: "interaction", title: a.action, body: a.details ?? undefined });
|
|
32614
|
+
return items.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
|
|
32615
|
+
}
|
|
32616
|
+
async getNetworkStats() {
|
|
32617
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
32618
|
+
const d30 = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
|
|
32619
|
+
const d60 = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
|
|
32620
|
+
const d7 = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
|
32621
|
+
const n = async (sql, params = []) => Number((await this.client.get(sql, params))?.c ?? 0);
|
|
32622
|
+
return {
|
|
32623
|
+
total_contacts: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false`),
|
|
32624
|
+
total_companies: await n(`SELECT COUNT(*) c FROM companies WHERE archived = false`),
|
|
32625
|
+
owned_entities: await n(`SELECT COUNT(*) c FROM companies WHERE is_owned_entity = true`),
|
|
32626
|
+
total_tags: await n(`SELECT COUNT(*) c FROM tags`),
|
|
32627
|
+
total_groups: await n(`SELECT COUNT(*) c FROM groups`),
|
|
32628
|
+
total_deals: await n(`SELECT COUNT(*) c FROM deals WHERE stage NOT IN ('won','lost','cancelled')`),
|
|
32629
|
+
total_events: await n(`SELECT COUNT(*) c FROM events`),
|
|
32630
|
+
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]),
|
|
32631
|
+
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]),
|
|
32632
|
+
cold_never: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND do_not_contact = false AND last_contacted_at IS NULL`),
|
|
32633
|
+
contacts_with_email: await n(`SELECT COUNT(DISTINCT contact_id) c FROM emails WHERE contact_id IS NOT NULL`),
|
|
32634
|
+
contacts_with_phone: await n(`SELECT COUNT(DISTINCT contact_id) c FROM phones WHERE contact_id IS NOT NULL`),
|
|
32635
|
+
contacts_no_company: await n(`SELECT COUNT(*) c FROM contacts WHERE archived = false AND company_id IS NULL`),
|
|
32636
|
+
overdue_tasks: await n(`SELECT COUNT(*) c FROM contact_tasks WHERE deadline < $1 AND status NOT IN ('completed','cancelled')`, [today]),
|
|
32637
|
+
pending_applications: await n(`SELECT COUNT(*) c FROM applications WHERE status IN ('submitted','pending','follow_up_needed')`),
|
|
32638
|
+
missing_invoices: await n(`SELECT COUNT(*) c FROM vendor_communications WHERE type = 'invoice_request' AND status IN ('awaiting_response','no_response')`),
|
|
32639
|
+
upcoming_7d: await n(`SELECT COUNT(*) c FROM contacts WHERE follow_up_at BETWEEN $1 AND $2`, [today, d7]),
|
|
32640
|
+
notes_count: await n(`SELECT COUNT(*) c FROM contact_notes`),
|
|
32641
|
+
active_deals_value: await n(`SELECT COALESCE(SUM(value_usd),0) c FROM deals WHERE stage NOT IN ('won','lost','cancelled') AND currency = 'USD'`)
|
|
32642
|
+
};
|
|
32643
|
+
}
|
|
32644
|
+
async getContactCard(contactId) {
|
|
32645
|
+
const contact = await this.getContact(contactId);
|
|
32646
|
+
if (!contact)
|
|
32647
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
32648
|
+
const details = await this.loadDetails(contact);
|
|
32649
|
+
return {
|
|
32650
|
+
id: details.id,
|
|
32651
|
+
display_name: details.display_name,
|
|
32652
|
+
job_title: details.job_title,
|
|
32653
|
+
company: details.company?.name,
|
|
32654
|
+
primary_email: details.emails?.find((e) => e.is_primary)?.address || details.emails?.[0]?.address,
|
|
32655
|
+
primary_phone: details.phones?.find((p) => p.is_primary)?.number || details.phones?.[0]?.number
|
|
32656
|
+
};
|
|
32657
|
+
}
|
|
32658
|
+
async getContactBrief(contactId, taskContext) {
|
|
32659
|
+
const contact = await this.getContact(contactId);
|
|
32660
|
+
if (!contact)
|
|
32661
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
32662
|
+
const details = await this.loadDetails(contact);
|
|
32663
|
+
const notes = (await this.listNotes(contactId)).slice(0, 3);
|
|
32664
|
+
const learnings = (await this.getLearnings(contactId, { min_importance: 7 })).slice(0, 5);
|
|
32665
|
+
const ctx = (taskContext ?? "").toLowerCase();
|
|
32666
|
+
const last = contact.last_contacted_at;
|
|
32667
|
+
const daysSince = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null;
|
|
32668
|
+
const brief = {
|
|
32669
|
+
id: contact.id,
|
|
32670
|
+
display_name: contact.display_name,
|
|
32671
|
+
job_title: contact.job_title,
|
|
32672
|
+
company: details.company?.name,
|
|
32673
|
+
status: contact.status,
|
|
32674
|
+
last_contacted: daysSince !== null ? `${daysSince}d ago` : "never",
|
|
32675
|
+
relationship_health: contact.relationship_health,
|
|
32676
|
+
engagement_status: contact.engagement_status,
|
|
32677
|
+
preferred_contact: contact.preferred_contact_method
|
|
32678
|
+
};
|
|
32679
|
+
if (ctx.includes("meeting") || ctx.includes("call") || ctx.includes("prep")) {
|
|
32680
|
+
brief.recent_notes = notes.map((nt) => ({ date: String(nt.created_at ?? "").slice(0, 10), content: nt.body }));
|
|
32681
|
+
brief.key_learnings = learnings.map((l) => l.content);
|
|
32682
|
+
}
|
|
32683
|
+
if (ctx.includes("outreach") || ctx.includes("email")) {
|
|
32684
|
+
brief.preferred_channel = contact.preferred_channel;
|
|
32685
|
+
brief.follow_up_at = contact.follow_up_at;
|
|
32686
|
+
}
|
|
32687
|
+
if (ctx.includes("deal"))
|
|
32688
|
+
brief.company_details = details.company ? { name: details.company.name, domain: details.company.domain } : null;
|
|
32689
|
+
if (learnings.length)
|
|
32690
|
+
brief.top_learnings = learnings.map((l) => l.content);
|
|
32691
|
+
return brief;
|
|
32692
|
+
}
|
|
32693
|
+
async assembleContext(contactIds, format) {
|
|
32694
|
+
const briefs = await Promise.all(contactIds.map(async (id) => {
|
|
32695
|
+
try {
|
|
32696
|
+
return await this.getContactBrief(id, format);
|
|
32697
|
+
} catch {
|
|
32698
|
+
return { id, error: "not found" };
|
|
32699
|
+
}
|
|
32700
|
+
}));
|
|
32701
|
+
return { format, contact_count: contactIds.length, assembled_at: new Date().toISOString(), contacts: briefs };
|
|
32702
|
+
}
|
|
32703
|
+
async generateBrief(contactId) {
|
|
32704
|
+
const contact = await this.getContact(contactId);
|
|
32705
|
+
if (!contact)
|
|
32706
|
+
throw new Error(`Contact not found: ${contactId}`);
|
|
32707
|
+
const details = await this.loadDetails(contact);
|
|
32708
|
+
const notes = await this.listNotes(contactId);
|
|
32709
|
+
const allTasks = await this.listContactTasks({ contact_id: contactId });
|
|
32710
|
+
const tasks = allTasks.filter((t) => !["completed", "cancelled"].includes(String(t.status)));
|
|
32711
|
+
const nowIsoStr = new Date().toISOString();
|
|
32712
|
+
const overdueTasks = allTasks.filter((t) => t.deadline && String(t.deadline) < nowIsoStr && !["completed", "cancelled"].includes(String(t.status)));
|
|
32713
|
+
const companyRels = await this.listCompanyRelationships({ contact_id: contactId });
|
|
32714
|
+
const recentTimeline = await this.getContactTimeline(contactId, 5);
|
|
32715
|
+
const last = contact.last_contacted_at;
|
|
32716
|
+
const daysSince = last ? Math.floor((Date.now() - new Date(last).getTime()) / 86400000) : null;
|
|
32717
|
+
const lines = [];
|
|
32718
|
+
lines.push(`# ${contact.display_name}`);
|
|
32719
|
+
if (contact.job_title)
|
|
32720
|
+
lines.push(`**Role:** ${contact.job_title}${contact.company_id ? ` (linked to company)` : ""}`);
|
|
32721
|
+
const emails = details.emails ?? [];
|
|
32722
|
+
const phones = details.phones ?? [];
|
|
32723
|
+
const pe = emails.find((e) => e.is_primary) || emails[0];
|
|
32724
|
+
if (pe)
|
|
32725
|
+
lines.push(`**Email:** ${pe.address}`);
|
|
32726
|
+
const pp = phones.find((p) => p.is_primary) || phones[0];
|
|
32727
|
+
if (pp)
|
|
32728
|
+
lines.push(`**Phone:** ${pp.number}`);
|
|
32729
|
+
if (contact.preferred_contact_method)
|
|
32730
|
+
lines.push(`**Preferred contact:** ${contact.preferred_contact_method}`);
|
|
32731
|
+
lines.push("");
|
|
32732
|
+
lines.push(`## Status`);
|
|
32733
|
+
lines.push(`- Last contacted: ${daysSince !== null ? `${daysSince} days ago` : "never"}`);
|
|
32734
|
+
lines.push(`- Status: ${contact.status || "active"}`);
|
|
32735
|
+
if (contact.follow_up_at)
|
|
32736
|
+
lines.push(`- Follow-up scheduled: ${contact.follow_up_at}`);
|
|
32737
|
+
if (overdueTasks.length)
|
|
32738
|
+
lines.push(`- OVERDUE TASKS: ${overdueTasks.length}`);
|
|
32739
|
+
if (companyRels.length) {
|
|
32740
|
+
lines.push("");
|
|
32741
|
+
lines.push(`## Entity Relationships`);
|
|
32742
|
+
for (const r of companyRels)
|
|
32743
|
+
lines.push(`- ${r.relationship_type} \u2014 ${r.notes || ""}`);
|
|
32744
|
+
}
|
|
32745
|
+
if (tasks.length) {
|
|
32746
|
+
lines.push("");
|
|
32747
|
+
lines.push(`## Open Tasks`);
|
|
32748
|
+
for (const t of tasks)
|
|
32749
|
+
lines.push(`- [${t.priority}] ${t.title}${t.deadline ? ` (due ${t.deadline})` : ""}`);
|
|
32750
|
+
}
|
|
32751
|
+
if (notes.length) {
|
|
32752
|
+
lines.push("");
|
|
32753
|
+
lines.push(`## Recent Notes`);
|
|
32754
|
+
for (const nt of notes.slice(0, 3))
|
|
32755
|
+
lines.push(`**${String(nt.created_at ?? "").slice(0, 10)}:** ${nt.body}`);
|
|
32756
|
+
}
|
|
32757
|
+
if (recentTimeline.length) {
|
|
32758
|
+
lines.push("");
|
|
32759
|
+
lines.push(`## Recent Activity`);
|
|
32760
|
+
for (const item of recentTimeline)
|
|
32761
|
+
lines.push(`- ${item.date.slice(0, 10)} ${item.title}`);
|
|
32762
|
+
}
|
|
32763
|
+
if (contact.notes) {
|
|
32764
|
+
lines.push("");
|
|
32765
|
+
lines.push(`## Background Notes`);
|
|
32766
|
+
lines.push(String(contact.notes));
|
|
32767
|
+
}
|
|
32768
|
+
return lines.join(`
|
|
32769
|
+
`);
|
|
32770
|
+
}
|
|
32771
|
+
async vaultStatus() {
|
|
32772
|
+
let document_count = 0;
|
|
32773
|
+
try {
|
|
32774
|
+
document_count = Number((await this.client.get(`SELECT COUNT(*) n FROM contact_documents`))?.n ?? 0);
|
|
32775
|
+
} catch {}
|
|
32776
|
+
return { initialized: false, unlocked: false, document_count };
|
|
32777
|
+
}
|
|
32778
|
+
}
|
|
32779
|
+
var cachedStore2 = null;
|
|
32780
|
+
function getContactsPgStore(client) {
|
|
32781
|
+
if (!cachedStore2)
|
|
32782
|
+
cachedStore2 = new ContactsPgStore(client);
|
|
32783
|
+
return cachedStore2;
|
|
32784
|
+
}
|
|
32785
|
+
|
|
32786
|
+
// src/server/v1.ts
|
|
32787
|
+
var JSON_HEADERS = { "Content-Type": "application/json" };
|
|
32788
|
+
function json5(body, status = 200) {
|
|
32789
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
32790
|
+
}
|
|
32791
|
+
function error2(status, message, extra) {
|
|
32792
|
+
return json5({ error: message, ...extra ?? {} }, status);
|
|
32793
|
+
}
|
|
32794
|
+
async function readJson(req) {
|
|
32795
|
+
try {
|
|
32796
|
+
const text = await req.text();
|
|
32797
|
+
if (!text)
|
|
32798
|
+
return {};
|
|
32799
|
+
return JSON.parse(text);
|
|
32800
|
+
} catch {
|
|
32801
|
+
return null;
|
|
32802
|
+
}
|
|
32803
|
+
}
|
|
32804
|
+
async function handleV1Request(req, url) {
|
|
32805
|
+
const path = url.pathname;
|
|
32806
|
+
if (path !== "/v1" && !path.startsWith("/v1/"))
|
|
32807
|
+
return null;
|
|
32808
|
+
const method = req.method.toUpperCase();
|
|
32809
|
+
const isWrite = method !== "GET" && method !== "HEAD";
|
|
32810
|
+
const requiredScopes = [isWrite ? `${CONTACTS_APP_SLUG}:write` : `${CONTACTS_APP_SLUG}:read`];
|
|
32811
|
+
let verifier;
|
|
32812
|
+
try {
|
|
32813
|
+
verifier = getCloudVerifier();
|
|
32814
|
+
} catch (e) {
|
|
32815
|
+
return error2(503, e.message);
|
|
32816
|
+
}
|
|
32817
|
+
const decision = await verifier.authenticate(req.headers, { method, path, requiredScopes });
|
|
32818
|
+
if (!decision.ok) {
|
|
32819
|
+
return error2(decision.status, decision.message, { reason: decision.reason });
|
|
32820
|
+
}
|
|
32821
|
+
await ensureCloudSchemaBestEffort();
|
|
32822
|
+
const store = getContactsPgStore(getCloudClient());
|
|
32823
|
+
const segments = path.split("/").filter(Boolean);
|
|
32824
|
+
const resource = segments[1];
|
|
32825
|
+
const id = segments[2];
|
|
32826
|
+
const sub = segments[3];
|
|
32827
|
+
const qp = (name) => url.searchParams.get(name) ?? undefined;
|
|
32828
|
+
const qn = (name) => {
|
|
32829
|
+
const v = url.searchParams.get(name);
|
|
32830
|
+
return v === null ? undefined : Number(v);
|
|
32831
|
+
};
|
|
32832
|
+
try {
|
|
32833
|
+
if (resource === "contacts" && id && sub) {
|
|
32834
|
+
if (method === "GET" && sub === "timeline")
|
|
32835
|
+
return json5({ timeline: await store.getContactTimeline(id, qn("limit") ?? 50) });
|
|
32836
|
+
if (method === "GET" && sub === "brief")
|
|
32837
|
+
return json5({ brief: await store.getContactBrief(id, qp("context")) });
|
|
32838
|
+
if (method === "GET" && sub === "brief-text")
|
|
32839
|
+
return json5({ text: await store.generateBrief(id) });
|
|
32840
|
+
if (method === "GET" && sub === "card")
|
|
32841
|
+
return json5({ card: await store.getContactCard(id) });
|
|
32842
|
+
if (method === "GET" && sub === "freshness")
|
|
32843
|
+
return json5({ freshness: await store.getFreshnessScore(id) });
|
|
32844
|
+
if (method === "GET" && sub === "signals")
|
|
32845
|
+
return json5({ signals: await store.getRelationshipSignals(id) });
|
|
32846
|
+
if (method === "GET" && sub === "notes")
|
|
32847
|
+
return json5({ notes: qp("company_id") ? await store.listNotesForContactAtCompany(id, qp("company_id")) : await store.listNotes(id) });
|
|
32848
|
+
if (method === "GET" && sub === "consent")
|
|
32849
|
+
return json5({ consent: await store.listContactConsent(id) });
|
|
32850
|
+
if (method === "GET" && sub === "identities")
|
|
32851
|
+
return json5({ identities: await store.getContactIdentities(id) });
|
|
32852
|
+
if (method === "GET" && sub === "groups")
|
|
32853
|
+
return json5({ groups: await store.listGroupsForContact(id) });
|
|
32854
|
+
if (method === "GET" && sub === "org-memberships")
|
|
32855
|
+
return json5({ org_members: await store.listOrgMembersForContact(id) });
|
|
32856
|
+
if (method === "GET" && sub === "relationships")
|
|
32857
|
+
return json5({ relationships: await store.listRelationships({ contact_id: id }) });
|
|
32858
|
+
if (method === "GET" && sub === "company-relationships")
|
|
32859
|
+
return json5({ relationships: await store.listCompanyRelationships({ contact_id: id }) });
|
|
32860
|
+
if (method === "GET" && sub === "field-history")
|
|
32861
|
+
return json5({ history: await store.getFieldHistory(id, qp("field_name")) });
|
|
32862
|
+
if (method === "GET" && sub === "field-at")
|
|
32863
|
+
return json5({ fields: await store.getContactAt(id, qp("timestamp") ?? new Date().toISOString()) });
|
|
32864
|
+
if (sub === "job-history") {
|
|
32865
|
+
if (method === "GET")
|
|
32866
|
+
return json5({ job_history: await store.getJobHistory(id) });
|
|
32867
|
+
if (method === "POST") {
|
|
32868
|
+
const body = await readJson(req);
|
|
32869
|
+
return json5({ job: await store.addJobEntry(id, body ?? {}) }, 201);
|
|
32870
|
+
}
|
|
32871
|
+
}
|
|
32872
|
+
if (sub === "learnings") {
|
|
32873
|
+
if (method === "GET")
|
|
32874
|
+
return json5({ learnings: await store.getLearnings(id, { type: qp("type"), min_importance: qn("min_importance"), visibility: qp("visibility") }) });
|
|
32875
|
+
if (method === "POST") {
|
|
32876
|
+
const body = await readJson(req);
|
|
32877
|
+
return json5({ learning: await store.saveLearning(id, body ?? {}) }, 201);
|
|
32878
|
+
}
|
|
32879
|
+
}
|
|
32880
|
+
if (sub === "consent" && method === "POST") {
|
|
32881
|
+
const body = await readJson(req);
|
|
32882
|
+
if (!body)
|
|
32883
|
+
return error2(400, "invalid JSON body");
|
|
32884
|
+
return json5({ consent: await store.setContactConsent(id, body.channel, body.status, body.source) });
|
|
32885
|
+
}
|
|
32886
|
+
if (sub === "field-verify" && method === "POST") {
|
|
32887
|
+
const body = await readJson(req);
|
|
32888
|
+
if (!body)
|
|
32889
|
+
return error2(400, "invalid JSON body");
|
|
32890
|
+
await store.markFieldVerified(id, body.field_name, body.source);
|
|
32891
|
+
return json5({ ok: true });
|
|
32892
|
+
}
|
|
32893
|
+
return error2(404, `unknown /v1/contacts/:id/${sub}`);
|
|
32894
|
+
}
|
|
32895
|
+
if (resource === "contacts") {
|
|
32896
|
+
if (!id) {
|
|
32897
|
+
if (method === "GET") {
|
|
32898
|
+
const result = await store.listContacts({
|
|
32899
|
+
...url.searchParams.get("company_id") ? { company_id: url.searchParams.get("company_id") } : {},
|
|
32900
|
+
...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
|
|
32901
|
+
...url.searchParams.get("q") ? { q: url.searchParams.get("q") } : {},
|
|
32902
|
+
...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
|
|
32903
|
+
...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
|
|
32904
|
+
});
|
|
32905
|
+
return json5(result);
|
|
32906
|
+
}
|
|
32907
|
+
if (method === "POST") {
|
|
32908
|
+
const body = await readJson(req);
|
|
32909
|
+
if (!body)
|
|
32910
|
+
return error2(400, "invalid JSON body");
|
|
32911
|
+
const contact = await store.createContact(body);
|
|
32912
|
+
return json5({ contact }, 201);
|
|
32913
|
+
}
|
|
32914
|
+
return error2(405, `method ${method} not allowed on /v1/contacts`);
|
|
32915
|
+
}
|
|
32916
|
+
if (method === "GET") {
|
|
32917
|
+
const contact = await store.getContact(id);
|
|
32918
|
+
return contact ? json5({ contact }) : error2(404, "contact not found");
|
|
32919
|
+
}
|
|
32920
|
+
if (method === "PATCH" || method === "PUT") {
|
|
32921
|
+
const body = await readJson(req);
|
|
32922
|
+
if (!body)
|
|
32923
|
+
return error2(400, "invalid JSON body");
|
|
32924
|
+
const contact = await store.updateContact(id, body);
|
|
32925
|
+
return contact ? json5({ contact }) : error2(404, "contact not found");
|
|
32926
|
+
}
|
|
32927
|
+
if (method === "DELETE") {
|
|
32928
|
+
const deleted = await store.deleteContact(id);
|
|
32929
|
+
return deleted ? json5({ deleted: true, id }) : error2(404, "contact not found");
|
|
32930
|
+
}
|
|
32931
|
+
return error2(405, `method ${method} not allowed on /v1/contacts/:id`);
|
|
32932
|
+
}
|
|
32933
|
+
if (resource === "companies") {
|
|
32934
|
+
if (!id) {
|
|
32935
|
+
if (method === "GET") {
|
|
32936
|
+
const result = await store.listCompanies({
|
|
32937
|
+
...url.searchParams.get("industry") ? { industry: url.searchParams.get("industry") } : {},
|
|
32938
|
+
...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
|
|
32939
|
+
...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
|
|
32940
|
+
});
|
|
32941
|
+
return json5(result);
|
|
32942
|
+
}
|
|
32943
|
+
if (method === "POST") {
|
|
32944
|
+
const body = await readJson(req);
|
|
32945
|
+
if (!body || typeof body.name !== "string" || !body.name.trim()) {
|
|
32946
|
+
return error2(400, "name is required");
|
|
32947
|
+
}
|
|
32948
|
+
const company = await store.createCompany(body);
|
|
32949
|
+
return json5({ company }, 201);
|
|
32950
|
+
}
|
|
32951
|
+
return error2(405, `method ${method} not allowed on /v1/companies`);
|
|
32952
|
+
}
|
|
32953
|
+
if (method === "GET") {
|
|
31520
32954
|
const company = await store.getCompany(id);
|
|
31521
32955
|
return company ? json5({ company }) : error2(404, "company not found");
|
|
31522
32956
|
}
|
|
@@ -31569,6 +33003,521 @@ async function handleV1Request(req, url) {
|
|
|
31569
33003
|
if (resource === "stats" && method === "GET") {
|
|
31570
33004
|
return json5(await store.stats());
|
|
31571
33005
|
}
|
|
33006
|
+
if (resource === "deals") {
|
|
33007
|
+
if (id && sub === "team" && method === "GET")
|
|
33008
|
+
return json5({ team: await store.getDealTeam(id) });
|
|
33009
|
+
if (id && sub === "roles" && method === "POST") {
|
|
33010
|
+
const b = await readJson(req);
|
|
33011
|
+
if (!b)
|
|
33012
|
+
return error2(400, "invalid JSON body");
|
|
33013
|
+
return json5({ role: await store.setDealContactRole(id, b.contact_id, b.account_role) }, 201);
|
|
33014
|
+
}
|
|
33015
|
+
if (!id) {
|
|
33016
|
+
if (method === "GET")
|
|
33017
|
+
return json5({ deals: await store.listDeals({ stage: qp("stage"), contact_id: qp("contact_id"), company_id: qp("company_id") }) });
|
|
33018
|
+
if (method === "POST") {
|
|
33019
|
+
const b = await readJson(req);
|
|
33020
|
+
if (!b)
|
|
33021
|
+
return error2(400, "invalid JSON body");
|
|
33022
|
+
return json5({ deal: await store.createDeal(b) }, 201);
|
|
33023
|
+
}
|
|
33024
|
+
return error2(405, `method ${method} not allowed on /v1/deals`);
|
|
33025
|
+
}
|
|
33026
|
+
if (method === "GET") {
|
|
33027
|
+
const d = await store.getDeal(id);
|
|
33028
|
+
return d ? json5({ deal: d }) : error2(404, "deal not found");
|
|
33029
|
+
}
|
|
33030
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33031
|
+
const b = await readJson(req);
|
|
33032
|
+
if (!b)
|
|
33033
|
+
return error2(400, "invalid JSON body");
|
|
33034
|
+
const d = await store.updateDeal(id, b);
|
|
33035
|
+
return d ? json5({ deal: d }) : error2(404, "deal not found");
|
|
33036
|
+
}
|
|
33037
|
+
if (method === "DELETE")
|
|
33038
|
+
return await store.deleteDeal(id) ? json5({ deleted: true, id }) : error2(404, "deal not found");
|
|
33039
|
+
return error2(405, "method not allowed");
|
|
33040
|
+
}
|
|
33041
|
+
if (resource === "events") {
|
|
33042
|
+
if (!id) {
|
|
33043
|
+
if (method === "GET")
|
|
33044
|
+
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") }) });
|
|
33045
|
+
if (method === "POST") {
|
|
33046
|
+
const b = await readJson(req);
|
|
33047
|
+
if (!b)
|
|
33048
|
+
return error2(400, "invalid JSON body");
|
|
33049
|
+
return json5({ event: await store.logEvent(b) }, 201);
|
|
33050
|
+
}
|
|
33051
|
+
return error2(405, "method not allowed");
|
|
33052
|
+
}
|
|
33053
|
+
if (method === "DELETE")
|
|
33054
|
+
return await store.deleteEvent(id) ? json5({ deleted: true, id }) : error2(404, "event not found");
|
|
33055
|
+
return error2(405, "method not allowed");
|
|
33056
|
+
}
|
|
33057
|
+
if (resource === "tasks") {
|
|
33058
|
+
if (id === "overdue" && method === "GET")
|
|
33059
|
+
return json5({ tasks: await store.listOverdueTasks() });
|
|
33060
|
+
if (id === "escalations" && method === "GET")
|
|
33061
|
+
return json5({ escalations: await store.checkEscalations() });
|
|
33062
|
+
if (!id) {
|
|
33063
|
+
if (method === "GET")
|
|
33064
|
+
return json5({ tasks: await store.listContactTasks({ contact_id: qp("contact_id"), entity_id: qp("entity_id"), status: qp("status"), priority: qp("priority") }) });
|
|
33065
|
+
if (method === "POST") {
|
|
33066
|
+
const b = await readJson(req);
|
|
33067
|
+
if (!b)
|
|
33068
|
+
return error2(400, "invalid JSON body");
|
|
33069
|
+
return json5({ task: await store.createContactTask(b) }, 201);
|
|
33070
|
+
}
|
|
33071
|
+
return error2(405, "method not allowed");
|
|
33072
|
+
}
|
|
33073
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33074
|
+
const b = await readJson(req);
|
|
33075
|
+
if (!b)
|
|
33076
|
+
return error2(400, "invalid JSON body");
|
|
33077
|
+
const t = await store.updateContactTask(id, b);
|
|
33078
|
+
return t ? json5({ task: t }) : error2(404, "task not found");
|
|
33079
|
+
}
|
|
33080
|
+
if (method === "DELETE")
|
|
33081
|
+
return await store.deleteContactTask(id) ? json5({ deleted: true, id }) : error2(404, "task not found");
|
|
33082
|
+
return error2(405, "method not allowed");
|
|
33083
|
+
}
|
|
33084
|
+
if (resource === "applications") {
|
|
33085
|
+
if (id === "follow-up-due" && method === "GET")
|
|
33086
|
+
return json5({ applications: await store.listFollowUpDueApplications() });
|
|
33087
|
+
if (!id) {
|
|
33088
|
+
if (method === "GET")
|
|
33089
|
+
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") }) });
|
|
33090
|
+
if (method === "POST") {
|
|
33091
|
+
const b = await readJson(req);
|
|
33092
|
+
if (!b)
|
|
33093
|
+
return error2(400, "invalid JSON body");
|
|
33094
|
+
return json5({ application: await store.createApplication(b) }, 201);
|
|
33095
|
+
}
|
|
33096
|
+
return error2(405, "method not allowed");
|
|
33097
|
+
}
|
|
33098
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33099
|
+
const b = await readJson(req);
|
|
33100
|
+
if (!b)
|
|
33101
|
+
return error2(400, "invalid JSON body");
|
|
33102
|
+
const a = await store.updateApplication(id, b);
|
|
33103
|
+
return a ? json5({ application: a }) : error2(404, "application not found");
|
|
33104
|
+
}
|
|
33105
|
+
return error2(405, "method not allowed");
|
|
33106
|
+
}
|
|
33107
|
+
if (resource === "groups") {
|
|
33108
|
+
if (id === "for-contact" && sub && method === "GET")
|
|
33109
|
+
return json5({ groups: await store.listGroupsForContact(sub) });
|
|
33110
|
+
if (id === "for-company" && sub && method === "GET")
|
|
33111
|
+
return json5({ groups: await store.listGroupsForCompany(sub) });
|
|
33112
|
+
if (id && sub === "contacts") {
|
|
33113
|
+
if (method === "GET")
|
|
33114
|
+
return json5({ contact_ids: await store.listContactsInGroup(id) });
|
|
33115
|
+
if (method === "POST") {
|
|
33116
|
+
const b = await readJson(req);
|
|
33117
|
+
if (!b?.contact_id)
|
|
33118
|
+
return error2(400, "contact_id required");
|
|
33119
|
+
return json5(await store.addContactToGroup(b.contact_id, id));
|
|
33120
|
+
}
|
|
33121
|
+
if (method === "DELETE") {
|
|
33122
|
+
const cid = segments[4];
|
|
33123
|
+
if (!cid)
|
|
33124
|
+
return error2(400, "contact id required");
|
|
33125
|
+
await store.removeContactFromGroup(cid, id);
|
|
33126
|
+
return json5({ ok: true });
|
|
33127
|
+
}
|
|
33128
|
+
}
|
|
33129
|
+
if (id && sub === "companies") {
|
|
33130
|
+
if (method === "GET")
|
|
33131
|
+
return json5({ company_ids: await store.listCompaniesInGroup(id) });
|
|
33132
|
+
if (method === "POST") {
|
|
33133
|
+
const b = await readJson(req);
|
|
33134
|
+
if (!b?.company_id)
|
|
33135
|
+
return error2(400, "company_id required");
|
|
33136
|
+
return json5(await store.addCompanyToGroup(b.company_id, id));
|
|
33137
|
+
}
|
|
33138
|
+
if (method === "DELETE") {
|
|
33139
|
+
const coid = segments[4];
|
|
33140
|
+
if (!coid)
|
|
33141
|
+
return error2(400, "company id required");
|
|
33142
|
+
await store.removeCompanyFromGroup(coid, id);
|
|
33143
|
+
return json5({ ok: true });
|
|
33144
|
+
}
|
|
33145
|
+
}
|
|
33146
|
+
if (!id) {
|
|
33147
|
+
if (method === "GET")
|
|
33148
|
+
return json5({ groups: await store.listGroups(qp("project_id")) });
|
|
33149
|
+
if (method === "POST") {
|
|
33150
|
+
const b = await readJson(req);
|
|
33151
|
+
if (!b)
|
|
33152
|
+
return error2(400, "invalid JSON body");
|
|
33153
|
+
return json5({ group: await store.createGroup(b) }, 201);
|
|
33154
|
+
}
|
|
33155
|
+
return error2(405, "method not allowed");
|
|
33156
|
+
}
|
|
33157
|
+
if (method === "GET") {
|
|
33158
|
+
const g = await store.getGroup(id);
|
|
33159
|
+
return g ? json5({ group: g }) : error2(404, "group not found");
|
|
33160
|
+
}
|
|
33161
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33162
|
+
const b = await readJson(req);
|
|
33163
|
+
if (!b)
|
|
33164
|
+
return error2(400, "invalid JSON body");
|
|
33165
|
+
const g = await store.updateGroup(id, b);
|
|
33166
|
+
return g ? json5({ group: g }) : error2(404, "group not found");
|
|
33167
|
+
}
|
|
33168
|
+
if (method === "DELETE")
|
|
33169
|
+
return await store.deleteGroup(id) ? json5({ deleted: true, id }) : error2(404, "group not found");
|
|
33170
|
+
return error2(405, "method not allowed");
|
|
33171
|
+
}
|
|
33172
|
+
if (resource === "vendor-comms") {
|
|
33173
|
+
if (id === "missing-invoices" && method === "GET")
|
|
33174
|
+
return json5({ communications: await store.listMissingInvoices() });
|
|
33175
|
+
if (id === "pending-follow-ups" && method === "GET")
|
|
33176
|
+
return json5({ communications: await store.listPendingFollowUps() });
|
|
33177
|
+
if (id && sub === "mark-done" && method === "POST") {
|
|
33178
|
+
const c = await store.markFollowUpDone(id);
|
|
33179
|
+
return c ? json5({ communication: c }) : error2(404, "not found");
|
|
33180
|
+
}
|
|
33181
|
+
if (!id) {
|
|
33182
|
+
if (method === "GET") {
|
|
33183
|
+
const companyId = qp("company_id");
|
|
33184
|
+
if (!companyId)
|
|
33185
|
+
return error2(400, "company_id required");
|
|
33186
|
+
return json5({ communications: await store.listVendorCommunications(companyId, { type: qp("type"), status: qp("status"), direction: qp("direction") }) });
|
|
33187
|
+
}
|
|
33188
|
+
if (method === "POST") {
|
|
33189
|
+
const b = await readJson(req);
|
|
33190
|
+
if (!b)
|
|
33191
|
+
return error2(400, "invalid JSON body");
|
|
33192
|
+
return json5({ communication: await store.logVendorCommunication(b) }, 201);
|
|
33193
|
+
}
|
|
33194
|
+
}
|
|
33195
|
+
return error2(405, "method not allowed");
|
|
33196
|
+
}
|
|
33197
|
+
if (resource === "org-members") {
|
|
33198
|
+
if (!id) {
|
|
33199
|
+
if (method === "GET") {
|
|
33200
|
+
if (qp("contact_id"))
|
|
33201
|
+
return json5({ org_members: await store.listOrgMembersForContact(qp("contact_id")) });
|
|
33202
|
+
if (qp("company_id"))
|
|
33203
|
+
return json5({ org_members: await store.listOrgMembers(qp("company_id")) });
|
|
33204
|
+
return error2(400, "company_id or contact_id required");
|
|
33205
|
+
}
|
|
33206
|
+
if (method === "POST") {
|
|
33207
|
+
const b = await readJson(req);
|
|
33208
|
+
if (!b)
|
|
33209
|
+
return error2(400, "invalid JSON body");
|
|
33210
|
+
return json5({ org_member: await store.addOrgMember(b) }, 201);
|
|
33211
|
+
}
|
|
33212
|
+
return error2(405, "method not allowed");
|
|
33213
|
+
}
|
|
33214
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33215
|
+
const b = await readJson(req);
|
|
33216
|
+
if (!b)
|
|
33217
|
+
return error2(400, "invalid JSON body");
|
|
33218
|
+
const m = await store.updateOrgMember(id, b);
|
|
33219
|
+
return m ? json5({ org_member: m }) : error2(404, "not found");
|
|
33220
|
+
}
|
|
33221
|
+
if (method === "DELETE")
|
|
33222
|
+
return await store.removeOrgMember(id) ? json5({ deleted: true, id }) : error2(404, "not found");
|
|
33223
|
+
return error2(405, "method not allowed");
|
|
33224
|
+
}
|
|
33225
|
+
if (resource === "notes") {
|
|
33226
|
+
if (!id) {
|
|
33227
|
+
if (method === "GET") {
|
|
33228
|
+
const cid = qp("contact_id");
|
|
33229
|
+
if (!cid)
|
|
33230
|
+
return error2(400, "contact_id required");
|
|
33231
|
+
return json5({ notes: qp("company_id") ? await store.listNotesForContactAtCompany(cid, qp("company_id")) : await store.listNotes(cid) });
|
|
33232
|
+
}
|
|
33233
|
+
if (method === "POST") {
|
|
33234
|
+
const b = await readJson(req);
|
|
33235
|
+
if (!b?.contact_id || !b?.body)
|
|
33236
|
+
return error2(400, "contact_id and body required");
|
|
33237
|
+
return json5({ note: await store.addNote(b.contact_id, b.body, b.created_by, b.company_id) }, 201);
|
|
33238
|
+
}
|
|
33239
|
+
return error2(405, "method not allowed");
|
|
33240
|
+
}
|
|
33241
|
+
if (method === "DELETE") {
|
|
33242
|
+
await store.deleteNote(id);
|
|
33243
|
+
return json5({ ok: true });
|
|
33244
|
+
}
|
|
33245
|
+
return error2(405, "method not allowed");
|
|
33246
|
+
}
|
|
33247
|
+
if (resource === "relationships") {
|
|
33248
|
+
if (!id) {
|
|
33249
|
+
if (method === "GET")
|
|
33250
|
+
return json5({ relationships: await store.listRelationships({ contact_id: qp("contact_id") }) });
|
|
33251
|
+
if (method === "POST") {
|
|
33252
|
+
const b = await readJson(req);
|
|
33253
|
+
if (!b)
|
|
33254
|
+
return error2(400, "invalid JSON body");
|
|
33255
|
+
return json5({ relationship: await store.createRelationship(b) }, 201);
|
|
33256
|
+
}
|
|
33257
|
+
}
|
|
33258
|
+
if (method === "DELETE" && id) {
|
|
33259
|
+
await store.deleteRelationship(id);
|
|
33260
|
+
return json5({ ok: true });
|
|
33261
|
+
}
|
|
33262
|
+
return error2(405, "method not allowed");
|
|
33263
|
+
}
|
|
33264
|
+
if (resource === "company-relationships") {
|
|
33265
|
+
if (!id) {
|
|
33266
|
+
if (method === "GET")
|
|
33267
|
+
return json5({ relationships: await store.listCompanyRelationships({ contact_id: qp("contact_id"), company_id: qp("company_id") }) });
|
|
33268
|
+
if (method === "POST") {
|
|
33269
|
+
const b = await readJson(req);
|
|
33270
|
+
if (!b)
|
|
33271
|
+
return error2(400, "invalid JSON body");
|
|
33272
|
+
return json5({ relationship: await store.createCompanyRelationship(b) }, 201);
|
|
33273
|
+
}
|
|
33274
|
+
}
|
|
33275
|
+
if (method === "DELETE" && id) {
|
|
33276
|
+
await store.deleteCompanyRelationship(id);
|
|
33277
|
+
return json5({ ok: true });
|
|
33278
|
+
}
|
|
33279
|
+
return error2(405, "method not allowed");
|
|
33280
|
+
}
|
|
33281
|
+
if (resource === "learnings") {
|
|
33282
|
+
if (id === "search" && method === "GET")
|
|
33283
|
+
return json5({ learnings: await store.searchLearnings(qp("q") ?? "", { type: qp("type"), contact_id: qp("contact_id") }) });
|
|
33284
|
+
if (id === "stale" && method === "GET")
|
|
33285
|
+
return json5({ learnings: await store.getStaleLearnings(qn("days_old") ?? 30, qn("min_confidence") ?? 0) });
|
|
33286
|
+
if (id === "maintenance" && method === "POST")
|
|
33287
|
+
return json5(await store.runLearningMaintenance());
|
|
33288
|
+
if (id && sub === "confirm" && method === "POST") {
|
|
33289
|
+
await store.confirmLearning(id);
|
|
33290
|
+
return json5({ ok: true });
|
|
33291
|
+
}
|
|
33292
|
+
return error2(404, "unknown /v1/learnings route");
|
|
33293
|
+
}
|
|
33294
|
+
if (resource === "locks") {
|
|
33295
|
+
if (id && method === "GET") {
|
|
33296
|
+
const l = await store.checkContactLock(id);
|
|
33297
|
+
return json5({ lock: l });
|
|
33298
|
+
}
|
|
33299
|
+
if (!id && method === "POST") {
|
|
33300
|
+
const b = await readJson(req);
|
|
33301
|
+
if (!b)
|
|
33302
|
+
return error2(400, "invalid JSON body");
|
|
33303
|
+
return json5(await store.acquireContactLock(b.contact_id, b.agent_name, b.ttl_seconds, b.reason, b.session_id));
|
|
33304
|
+
}
|
|
33305
|
+
if (id && method === "DELETE") {
|
|
33306
|
+
const released = await store.releaseContactLock(id, qp("agent_name") ?? "");
|
|
33307
|
+
return json5({ released });
|
|
33308
|
+
}
|
|
33309
|
+
return error2(405, "method not allowed");
|
|
33310
|
+
}
|
|
33311
|
+
if (resource === "activity") {
|
|
33312
|
+
if (method === "GET") {
|
|
33313
|
+
const cid = qp("contact_id");
|
|
33314
|
+
if (!cid)
|
|
33315
|
+
return error2(400, "contact_id required");
|
|
33316
|
+
return json5({ activity: await store.getAgentActivity(cid, qn("limit") ?? 20) });
|
|
33317
|
+
}
|
|
33318
|
+
if (method === "POST") {
|
|
33319
|
+
const b = await readJson(req);
|
|
33320
|
+
if (!b)
|
|
33321
|
+
return error2(400, "invalid JSON body");
|
|
33322
|
+
await store.logAgentActivity(b.contact_id, b.agent_name, b.action, b.details, b.session_id);
|
|
33323
|
+
return json5({ ok: true }, 201);
|
|
33324
|
+
}
|
|
33325
|
+
return error2(405, "method not allowed");
|
|
33326
|
+
}
|
|
33327
|
+
if (resource === "identity") {
|
|
33328
|
+
if (id === "resolve" && method === "POST") {
|
|
33329
|
+
const b = await readJson(req);
|
|
33330
|
+
if (!b)
|
|
33331
|
+
return error2(400, "invalid JSON body");
|
|
33332
|
+
return json5({ matches: await store.resolveContactIdentity(b) });
|
|
33333
|
+
}
|
|
33334
|
+
if (!id) {
|
|
33335
|
+
if (method === "GET") {
|
|
33336
|
+
const cid = qp("contact_id");
|
|
33337
|
+
if (!cid)
|
|
33338
|
+
return error2(400, "contact_id required");
|
|
33339
|
+
return json5({ identities: await store.getContactIdentities(cid) });
|
|
33340
|
+
}
|
|
33341
|
+
if (method === "POST") {
|
|
33342
|
+
const b = await readJson(req);
|
|
33343
|
+
if (!b)
|
|
33344
|
+
return error2(400, "invalid JSON body");
|
|
33345
|
+
return json5({ identity: await store.addContactIdentity(b.contact_id, b.system, b.external_id, b.external_url, b.confidence) }, 201);
|
|
33346
|
+
}
|
|
33347
|
+
}
|
|
33348
|
+
return error2(405, "method not allowed");
|
|
33349
|
+
}
|
|
33350
|
+
if (resource === "signals") {
|
|
33351
|
+
if (id === "ghost" && method === "GET")
|
|
33352
|
+
return json5({ signals: await store.getGhostContacts() });
|
|
33353
|
+
if (id === "warming" && method === "GET")
|
|
33354
|
+
return json5({ signals: await store.getWarmingContacts() });
|
|
33355
|
+
if (id === "recompute" && method === "POST")
|
|
33356
|
+
return json5(await store.recomputeSignals());
|
|
33357
|
+
if (!id && method === "GET") {
|
|
33358
|
+
const cid = qp("contact_id");
|
|
33359
|
+
if (!cid)
|
|
33360
|
+
return error2(400, "contact_id required");
|
|
33361
|
+
return json5({ signals: await store.getRelationshipSignals(cid) });
|
|
33362
|
+
}
|
|
33363
|
+
return error2(405, "method not allowed");
|
|
33364
|
+
}
|
|
33365
|
+
if (resource === "freshness") {
|
|
33366
|
+
if (id === "stale" && method === "GET")
|
|
33367
|
+
return json5({ contacts: await store.getStaleContacts(qn("threshold") ?? 40) });
|
|
33368
|
+
if (id === "verify" && method === "POST") {
|
|
33369
|
+
const b = await readJson(req);
|
|
33370
|
+
if (!b)
|
|
33371
|
+
return error2(400, "invalid JSON body");
|
|
33372
|
+
await store.markFieldVerified(b.contact_id, b.field_name, b.source);
|
|
33373
|
+
return json5({ ok: true });
|
|
33374
|
+
}
|
|
33375
|
+
if (id && method === "GET")
|
|
33376
|
+
return json5({ freshness: await store.getFreshnessScore(id) });
|
|
33377
|
+
return error2(405, "method not allowed");
|
|
33378
|
+
}
|
|
33379
|
+
if (resource === "graph") {
|
|
33380
|
+
if (id === "strength" && sub && method === "GET")
|
|
33381
|
+
return json5({ strength: await store.computeRelationshipStrength(sub) });
|
|
33382
|
+
if (id === "warm-path" && method === "GET")
|
|
33383
|
+
return json5({ path: await store.findWarmPath(qp("from") ?? "", qp("to") ?? "") });
|
|
33384
|
+
if (id === "company" && sub && method === "GET")
|
|
33385
|
+
return json5({ connections: await store.findConnectionsAtCompany(sub) });
|
|
33386
|
+
if (id === "cooling" && method === "GET")
|
|
33387
|
+
return json5({ cooling: await store.detectCoolingRelationships() });
|
|
33388
|
+
return error2(404, "unknown /v1/graph route");
|
|
33389
|
+
}
|
|
33390
|
+
if (resource === "org-chart") {
|
|
33391
|
+
if (id === "coverage" && sub && method === "GET")
|
|
33392
|
+
return json5({ coverage: await store.getCoverageGaps(sub) });
|
|
33393
|
+
if (!id) {
|
|
33394
|
+
if (method === "GET") {
|
|
33395
|
+
const cid = qp("company_id");
|
|
33396
|
+
if (!cid)
|
|
33397
|
+
return error2(400, "company_id required");
|
|
33398
|
+
return json5({ edges: await store.listOrgChart(cid) });
|
|
33399
|
+
}
|
|
33400
|
+
if (method === "POST") {
|
|
33401
|
+
const b = await readJson(req);
|
|
33402
|
+
if (!b)
|
|
33403
|
+
return error2(400, "invalid JSON body");
|
|
33404
|
+
return json5({ edge: await store.addOrgChartEdge(b.company_id, b.contact_a_id, b.contact_b_id, b.edge_type, b.inferred) }, 201);
|
|
33405
|
+
}
|
|
33406
|
+
}
|
|
33407
|
+
return error2(405, "method not allowed");
|
|
33408
|
+
}
|
|
33409
|
+
if (resource === "audiences") {
|
|
33410
|
+
if (id && sub === "resolve" && method === "GET")
|
|
33411
|
+
return json5({ resolution: await store.resolveAudience(id, qp("channel") ?? "email") });
|
|
33412
|
+
if (!id) {
|
|
33413
|
+
if (method === "GET")
|
|
33414
|
+
return json5({ audiences: await store.listAudiences() });
|
|
33415
|
+
if (method === "POST") {
|
|
33416
|
+
const b = await readJson(req);
|
|
33417
|
+
if (!b)
|
|
33418
|
+
return error2(400, "invalid JSON body");
|
|
33419
|
+
return json5({ audience: await store.createAudience(b) }, 201);
|
|
33420
|
+
}
|
|
33421
|
+
return error2(405, "method not allowed");
|
|
33422
|
+
}
|
|
33423
|
+
if (method === "GET")
|
|
33424
|
+
return json5({ audience: await store.getAudience(id) });
|
|
33425
|
+
if (method === "PATCH" || method === "PUT") {
|
|
33426
|
+
const b = await readJson(req);
|
|
33427
|
+
if (!b)
|
|
33428
|
+
return error2(400, "invalid JSON body");
|
|
33429
|
+
return json5({ audience: await store.updateAudience(id, b) });
|
|
33430
|
+
}
|
|
33431
|
+
if (method === "DELETE") {
|
|
33432
|
+
await store.deleteAudience(id);
|
|
33433
|
+
return json5({ deleted: true, id });
|
|
33434
|
+
}
|
|
33435
|
+
return error2(405, "method not allowed");
|
|
33436
|
+
}
|
|
33437
|
+
if (resource === "consent") {
|
|
33438
|
+
if (method === "GET") {
|
|
33439
|
+
const cid = qp("contact_id");
|
|
33440
|
+
if (!cid)
|
|
33441
|
+
return error2(400, "contact_id required");
|
|
33442
|
+
return json5({ consent: await store.listContactConsent(cid) });
|
|
33443
|
+
}
|
|
33444
|
+
if (method === "POST") {
|
|
33445
|
+
const b = await readJson(req);
|
|
33446
|
+
if (!b)
|
|
33447
|
+
return error2(400, "invalid JSON body");
|
|
33448
|
+
return json5({ consent: await store.setContactConsent(b.contact_id, b.channel, b.status, b.source) });
|
|
33449
|
+
}
|
|
33450
|
+
return error2(405, "method not allowed");
|
|
33451
|
+
}
|
|
33452
|
+
if (resource === "suppressions") {
|
|
33453
|
+
if (method === "GET")
|
|
33454
|
+
return json5({ suppressions: await store.listSuppressions({ channel: qp("channel"), unsyncedOnly: qp("unsynced") === "1" || qp("unsynced") === "true" }) });
|
|
33455
|
+
if (method === "POST") {
|
|
33456
|
+
const b = await readJson(req);
|
|
33457
|
+
if (!b)
|
|
33458
|
+
return error2(400, "invalid JSON body");
|
|
33459
|
+
return json5({ suppression: await store.suppressAddress(b) }, 201);
|
|
33460
|
+
}
|
|
33461
|
+
if (method === "DELETE") {
|
|
33462
|
+
const channel = qp("channel");
|
|
33463
|
+
const address = qp("address");
|
|
33464
|
+
if (!channel || !address)
|
|
33465
|
+
return error2(400, "channel and address required");
|
|
33466
|
+
await store.unsuppressAddress(channel, address);
|
|
33467
|
+
return json5({ ok: true });
|
|
33468
|
+
}
|
|
33469
|
+
return error2(405, "method not allowed");
|
|
33470
|
+
}
|
|
33471
|
+
if (resource === "field-history" && method === "GET") {
|
|
33472
|
+
const cid = qp("contact_id");
|
|
33473
|
+
if (!cid)
|
|
33474
|
+
return error2(400, "contact_id required");
|
|
33475
|
+
if (id === "at")
|
|
33476
|
+
return json5({ fields: await store.getContactAt(cid, qp("timestamp") ?? new Date().toISOString()) });
|
|
33477
|
+
return json5({ history: await store.getFieldHistory(cid, qp("field_name")) });
|
|
33478
|
+
}
|
|
33479
|
+
if (resource === "job-history") {
|
|
33480
|
+
if (method === "GET") {
|
|
33481
|
+
const cid = qp("contact_id");
|
|
33482
|
+
if (!cid)
|
|
33483
|
+
return error2(400, "contact_id required");
|
|
33484
|
+
return json5({ job_history: await store.getJobHistory(cid) });
|
|
33485
|
+
}
|
|
33486
|
+
if (method === "POST") {
|
|
33487
|
+
const b = await readJson(req);
|
|
33488
|
+
if (!b?.contact_id)
|
|
33489
|
+
return error2(400, "contact_id required");
|
|
33490
|
+
return json5({ job: await store.addJobEntry(b.contact_id, b) }, 201);
|
|
33491
|
+
}
|
|
33492
|
+
}
|
|
33493
|
+
if (resource === "cold-contacts" && method === "GET")
|
|
33494
|
+
return json5({ contacts: await store.listColdContacts(qn("days") ?? 30) });
|
|
33495
|
+
if (resource === "not-contacted" && method === "GET")
|
|
33496
|
+
return json5({ contacts: await store.listContactsNotContactedSince(qn("days") ?? 90, qn("limit") ?? 50) });
|
|
33497
|
+
if (resource === "followup-due-contacts" && method === "GET")
|
|
33498
|
+
return json5({ contacts: await store.listFollowupDueContacts(qp("on_or_before") ?? new Date().toISOString()) });
|
|
33499
|
+
if (resource === "contacts-for-context" && method === "GET")
|
|
33500
|
+
return json5({ contacts: await store.findContactsForContext(qp("topic") ?? "", qn("limit") ?? 20) });
|
|
33501
|
+
if (resource === "email-duplicates" && method === "GET")
|
|
33502
|
+
return json5({ duplicates: await store.findEmailDuplicates() });
|
|
33503
|
+
if (resource === "name-duplicates" && method === "GET")
|
|
33504
|
+
return json5({ duplicates: await store.findNameDuplicates() });
|
|
33505
|
+
if (resource === "contact-audit" && method === "GET")
|
|
33506
|
+
return json5({ audit: await store.listContactAudit() });
|
|
33507
|
+
if (resource === "upcoming" && method === "GET")
|
|
33508
|
+
return json5({ items: await store.getUpcomingItems(qn("days") ?? 7) });
|
|
33509
|
+
if (resource === "network-stats" && method === "GET")
|
|
33510
|
+
return json5({ stats: await store.getNetworkStats() });
|
|
33511
|
+
if (resource === "recent-events" && method === "GET")
|
|
33512
|
+
return json5({ events: await store.getRecentContactEvents(qp("since"), qp("types") ? qp("types").split(",") : undefined) });
|
|
33513
|
+
if (resource === "vault-status" && method === "GET")
|
|
33514
|
+
return json5({ vault: await store.vaultStatus() });
|
|
33515
|
+
if (resource === "assemble-context" && method === "POST") {
|
|
33516
|
+
const b = await readJson(req);
|
|
33517
|
+
if (!b)
|
|
33518
|
+
return error2(400, "invalid JSON body");
|
|
33519
|
+
return json5({ context: await store.assembleContext(b.contact_ids ?? [], b.format ?? "meeting_prep") });
|
|
33520
|
+
}
|
|
31572
33521
|
return error2(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
|
|
31573
33522
|
} catch (e) {
|
|
31574
33523
|
const msg = e.message || "internal error";
|