@talkpilot/core-db 1.3.87 → 1.3.89

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.
Files changed (121) hide show
  1. package/README.md +65 -12
  2. package/dist/talkpilot/auditLogs/auditLogs.getters.d.ts +8 -0
  3. package/dist/talkpilot/auditLogs/auditLogs.getters.d.ts.map +1 -0
  4. package/dist/talkpilot/auditLogs/auditLogs.getters.js +26 -0
  5. package/dist/talkpilot/auditLogs/auditLogs.getters.js.map +1 -0
  6. package/dist/talkpilot/auditLogs/auditLogs.types.d.ts +11 -0
  7. package/dist/talkpilot/auditLogs/auditLogs.types.d.ts.map +1 -0
  8. package/dist/talkpilot/auditLogs/auditLogs.types.js +3 -0
  9. package/dist/talkpilot/auditLogs/auditLogs.types.js.map +1 -0
  10. package/dist/talkpilot/auditLogs/index.d.ts +3 -0
  11. package/dist/talkpilot/auditLogs/index.d.ts.map +1 -0
  12. package/dist/talkpilot/auditLogs/index.js +19 -0
  13. package/dist/talkpilot/auditLogs/index.js.map +1 -0
  14. package/dist/talkpilot/calls/calls.constants.d.ts +11 -1
  15. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -1
  16. package/dist/talkpilot/calls/calls.constants.js +11 -1
  17. package/dist/talkpilot/calls/calls.constants.js.map +1 -1
  18. package/dist/talkpilot/calls/calls.getters.d.ts +8 -1
  19. package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
  20. package/dist/talkpilot/calls/calls.getters.js +216 -1
  21. package/dist/talkpilot/calls/calls.getters.js.map +1 -1
  22. package/dist/talkpilot/calls/calls.types.d.ts +45 -0
  23. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  24. package/dist/talkpilot/clients/clients.constants.d.ts +4 -0
  25. package/dist/talkpilot/clients/clients.constants.d.ts.map +1 -0
  26. package/dist/talkpilot/clients/clients.constants.js +7 -0
  27. package/dist/talkpilot/clients/clients.constants.js.map +1 -0
  28. package/dist/talkpilot/clients/clients.getters.d.ts +6 -0
  29. package/dist/talkpilot/clients/clients.getters.d.ts.map +1 -1
  30. package/dist/talkpilot/clients/clients.getters.js +62 -1
  31. package/dist/talkpilot/clients/clients.getters.js.map +1 -1
  32. package/dist/talkpilot/clients/clients.lifecycle.d.ts +10 -0
  33. package/dist/talkpilot/clients/clients.lifecycle.d.ts.map +1 -0
  34. package/dist/talkpilot/clients/clients.lifecycle.js +109 -0
  35. package/dist/talkpilot/clients/clients.lifecycle.js.map +1 -0
  36. package/dist/talkpilot/clients/clients.types.d.ts +53 -0
  37. package/dist/talkpilot/clients/clients.types.d.ts.map +1 -1
  38. package/dist/talkpilot/clients/index.d.ts +2 -0
  39. package/dist/talkpilot/clients/index.d.ts.map +1 -1
  40. package/dist/talkpilot/clients/index.js +2 -0
  41. package/dist/talkpilot/clients/index.js.map +1 -1
  42. package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts +1 -7
  43. package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts.map +1 -1
  44. package/dist/talkpilot/contextNotes/contextNotes.getters.js +1 -37
  45. package/dist/talkpilot/contextNotes/contextNotes.getters.js.map +1 -1
  46. package/dist/talkpilot/contextNotes/contextNotes.types.d.ts +0 -6
  47. package/dist/talkpilot/contextNotes/contextNotes.types.d.ts.map +1 -1
  48. package/dist/talkpilot/index.d.ts +1 -0
  49. package/dist/talkpilot/index.d.ts.map +1 -1
  50. package/dist/talkpilot/index.js +1 -0
  51. package/dist/talkpilot/index.js.map +1 -1
  52. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +9 -1
  53. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
  54. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +70 -1
  55. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
  56. package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +5 -0
  57. package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts.map +1 -1
  58. package/dist/test-utils/factories/index.d.ts +2 -0
  59. package/dist/test-utils/factories/index.d.ts.map +1 -1
  60. package/dist/test-utils/factories/index.js +2 -0
  61. package/dist/test-utils/factories/index.js.map +1 -1
  62. package/dist/test-utils/factories/talkpilot/clients.d.ts +14 -0
  63. package/dist/test-utils/factories/talkpilot/clients.d.ts.map +1 -0
  64. package/dist/test-utils/factories/talkpilot/clients.js +19 -0
  65. package/dist/test-utils/factories/talkpilot/clients.js.map +1 -0
  66. package/dist/test-utils/factories/talkpilot/results.d.ts +16 -0
  67. package/dist/test-utils/factories/talkpilot/results.d.ts.map +1 -0
  68. package/dist/test-utils/factories/talkpilot/results.js +40 -0
  69. package/dist/test-utils/factories/talkpilot/results.js.map +1 -0
  70. package/dist/utils/regex.utils.d.ts +2 -0
  71. package/dist/utils/regex.utils.d.ts.map +1 -0
  72. package/dist/utils/regex.utils.js +6 -0
  73. package/dist/utils/regex.utils.js.map +1 -0
  74. package/dist/websitalk/scanResources/scanResources.getters.d.ts.map +1 -1
  75. package/dist/websitalk/scanResources/scanResources.getters.js +2 -1
  76. package/dist/websitalk/scanResources/scanResources.getters.js.map +1 -1
  77. package/dist/websitalk/scanResources/scanResources.utils.d.ts +1 -1
  78. package/dist/websitalk/scanResources/scanResources.utils.d.ts.map +1 -1
  79. package/dist/websitalk/scanResources/scanResources.utils.js +3 -3
  80. package/dist/websitalk/scanResources/scanResources.utils.js.map +1 -1
  81. package/package.json +1 -1
  82. package/src/talkpilot/auditLogs/__tests__/auditLogs.spec.ts +91 -0
  83. package/src/talkpilot/auditLogs/auditLogs.getters.ts +26 -0
  84. package/src/talkpilot/auditLogs/auditLogs.types.ts +12 -0
  85. package/src/talkpilot/auditLogs/index.ts +2 -0
  86. package/src/talkpilot/calls/__tests__/calls.statistics.getters.spec.ts +559 -0
  87. package/src/talkpilot/calls/calls.constants.ts +14 -0
  88. package/src/talkpilot/calls/calls.getters.ts +320 -0
  89. package/src/talkpilot/calls/calls.types.ts +52 -0
  90. package/src/talkpilot/clients/__tests__/clients.getters.spec.ts +224 -0
  91. package/src/talkpilot/clients/__tests__/clients.lifecycle.spec.ts +194 -0
  92. package/src/talkpilot/clients/clients.constants.ts +4 -0
  93. package/src/talkpilot/clients/clients.getters.ts +91 -2
  94. package/src/talkpilot/clients/clients.lifecycle.ts +132 -0
  95. package/src/talkpilot/clients/clients.types.ts +58 -0
  96. package/src/talkpilot/clients/index.ts +2 -0
  97. package/src/talkpilot/contextNotes/__tests__/contextNotes.getters.spec.ts +0 -141
  98. package/src/talkpilot/contextNotes/contextNotes.getters.ts +1 -75
  99. package/src/talkpilot/contextNotes/contextNotes.types.ts +0 -7
  100. package/src/talkpilot/index.ts +1 -0
  101. package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +284 -13
  102. package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +89 -1
  103. package/src/talkpilot/phone_numbers/phone_numbers.types.ts +6 -0
  104. package/src/test-utils/factories/index.ts +2 -0
  105. package/src/test-utils/factories/talkpilot/clients.ts +21 -0
  106. package/src/test-utils/factories/talkpilot/results.ts +43 -0
  107. package/src/utils/regex.utils.ts +2 -0
  108. package/src/websitalk/scanResources/scanResources.getters.ts +2 -6
  109. package/src/websitalk/scanResources/scanResources.utils.ts +2 -3
  110. package/dist/configuration/gptLive.seed.d.ts +0 -2
  111. package/dist/configuration/gptLive.seed.d.ts.map +0 -1
  112. package/dist/configuration/gptLive.seed.js +0 -59
  113. package/dist/configuration/gptLive.seed.js.map +0 -1
  114. package/dist/talkpilot/clients/clients.quota.constants.d.ts +0 -2
  115. package/dist/talkpilot/clients/clients.quota.constants.d.ts.map +0 -1
  116. package/dist/talkpilot/clients/clients.quota.constants.js +0 -5
  117. package/dist/talkpilot/clients/clients.quota.constants.js.map +0 -1
  118. package/dist/talkpilot/clients/clients.quota.utils.d.ts +0 -8
  119. package/dist/talkpilot/clients/clients.quota.utils.d.ts.map +0 -1
  120. package/dist/talkpilot/clients/clients.quota.utils.js +0 -45
  121. package/dist/talkpilot/clients/clients.quota.utils.js.map +0 -1
@@ -1,10 +1,6 @@
1
1
  import { Collection, ObjectId, WithId } from "mongodb";
2
2
  import { getDb } from "../index";
3
- import type {
4
- ContextNote,
5
- ContextNoteEntry,
6
- ContextNoteEntryWrite,
7
- } from "./contextNotes.types";
3
+ import type { ContextNote, ContextNoteEntry } from "./contextNotes.types";
8
4
 
9
5
  export const getContextNotesCollection = (): Collection<ContextNote> => {
10
6
  return getDb().collection<ContextNote>("contextNotes");
@@ -75,76 +71,6 @@ export const setContextNoteConfig = async (
75
71
  return result.matchedCount > 0;
76
72
  };
77
73
 
78
- const findNoteEntryById = (
79
- doc: WithId<ContextNote> | null,
80
- noteId: ObjectId,
81
- ): ContextNoteEntry | null =>
82
- doc?.notes.find((note) => note._id.equals(noteId)) ?? null;
83
-
84
- /** Appends one note. Other notes on the document are left untouched. */
85
- export const appendContextNoteEntry = async (
86
- clientId: string,
87
- product: string,
88
- entry: ContextNoteEntryWrite,
89
- now: Date = new Date(),
90
- ): Promise<ContextNoteEntry> => {
91
- const note: ContextNoteEntry = {
92
- _id: new ObjectId(),
93
- title: entry.title,
94
- content: entry.content,
95
- activeFrom: entry.activeFrom,
96
- expiresAt: entry.expiresAt,
97
- createdAt: now,
98
- updatedAt: now,
99
- };
100
-
101
- const saved = await getContextNotesCollection().findOneAndUpdate(
102
- { clientId, product },
103
- { $push: { notes: note } },
104
- { upsert: true, returnDocument: "after" },
105
- );
106
-
107
- return findNoteEntryById(saved, note._id) ?? note;
108
- };
109
-
110
- /** Replaces one note's fields. Other notes on the document are left untouched. */
111
- export const updateContextNoteEntryFields = async (
112
- clientId: string,
113
- product: string,
114
- noteId: ObjectId,
115
- entry: ContextNoteEntryWrite,
116
- now: Date = new Date(),
117
- ): Promise<ContextNoteEntry | null> => {
118
- const saved = await getContextNotesCollection().findOneAndUpdate(
119
- { clientId, product, "notes._id": noteId },
120
- {
121
- $set: {
122
- "notes.$.title": entry.title,
123
- "notes.$.content": entry.content,
124
- "notes.$.activeFrom": entry.activeFrom,
125
- "notes.$.expiresAt": entry.expiresAt,
126
- "notes.$.updatedAt": now,
127
- },
128
- },
129
- { returnDocument: "after" },
130
- );
131
-
132
- return findNoteEntryById(saved, noteId);
133
- };
134
-
135
- /** Removes one note by id. Other notes on the document are left untouched. */
136
- export const pullContextNoteEntry = async (
137
- clientId: string,
138
- product: string,
139
- noteId: ObjectId,
140
- ): Promise<boolean> => {
141
- const result = await getContextNotesCollection().updateOne(
142
- { clientId, product, "notes._id": noteId },
143
- { $pull: { notes: { _id: noteId } } },
144
- );
145
- return result.modifiedCount > 0;
146
- };
147
-
148
74
  /** Removes a single note entry from a document by its _id. */
149
75
  export const deleteContextNoteEntry = async (
150
76
  id: ObjectId,
@@ -10,13 +10,6 @@ export type ContextNoteEntry = {
10
10
  updatedAt: Date;
11
11
  };
12
12
 
13
- export type ContextNoteEntryWrite = {
14
- title: string;
15
- content: string;
16
- activeFrom: Date;
17
- expiresAt: Date | null;
18
- };
19
-
20
13
  export type ContextNote = {
21
14
  _id: ObjectId;
22
15
  clientId: string;
@@ -2,6 +2,7 @@ import { Db, ObjectId as MongoObjectId } from "mongodb";
2
2
 
3
3
  export * from "./flows";
4
4
  export * from "./contextNotes";
5
+ export * from "./auditLogs";
5
6
  export * from "./products";
6
7
  export * from "./results";
7
8
  export * from "./sessions";
@@ -7,6 +7,11 @@ import {
7
7
  createPhoneNumberEntity,
8
8
  createPurchasedPhoneNumber,
9
9
  getPrimaryPhoneFlowId,
10
+ getClientPhones,
11
+ findPhonesByClientIds,
12
+ getDistinctClientPhonePairs,
13
+ findClientsByPhoneNumbers,
14
+ deletePhoneNumberRecords,
10
15
  findPhoneNumbersByClientId,
11
16
  getOwnedPhoneNumber,
12
17
  getPhoneNumberByClientAndFlow,
@@ -20,7 +25,7 @@ import { ObjectId } from "mongodb";
20
25
 
21
26
  describe("db.phoneNumbers", () => {
22
27
  describe("getPhoneDataByPhoneNumber", () => {
23
- it("return phone number data with flow", async () => {
28
+ it("should return phone number data with flow", async () => {
24
29
  const flow = createFlow({
25
30
  clientId: "test-client-id",
26
31
  conversationSettings: {
@@ -68,7 +73,7 @@ describe("db.phoneNumbers", () => {
68
73
  });
69
74
 
70
75
  describe("getClientPhoneNumber", () => {
71
- it("return phone number by client id", async () => {
76
+ it("should return phone number by client id", async () => {
72
77
  const flow = createFlow();
73
78
  const phoneData = createPhoneNumber({
74
79
  flow_id: flow._id,
@@ -84,7 +89,7 @@ describe("db.phoneNumbers", () => {
84
89
  });
85
90
 
86
91
  describe("getClientPhoneData", () => {
87
- it("returns primary phone data by default", async () => {
92
+ it("should return primary phone data by default", async () => {
88
93
  const clientId = "multiPhoneClient";
89
94
  const phone1 = createPhoneNumber({
90
95
  client_id: clientId,
@@ -106,7 +111,7 @@ describe("db.phoneNumbers", () => {
106
111
  expect(result?.phone_number).toBe("2");
107
112
  });
108
113
 
109
- it("returns most recent non-primary phone data if isPrimary is false", async () => {
114
+ it("should return most recent non-primary phone data if isPrimary is false", async () => {
110
115
  const clientId = "multiPhoneClient2";
111
116
  const phone1 = createPhoneNumber({
112
117
  client_id: clientId,
@@ -137,7 +142,7 @@ describe("db.phoneNumbers", () => {
137
142
  });
138
143
 
139
144
  describe("getPhoneNumbersForFlows", () => {
140
- it("returns all phone numbers for the client with flow ObjectIds, newest first", async () => {
145
+ it("should return all phone numbers for the client with flow ObjectIds, newest first", async () => {
141
146
  const clientId = "flowsClient";
142
147
  const flow1 = createFlow({ clientId });
143
148
  const flow2 = createFlow({ clientId });
@@ -175,12 +180,12 @@ describe("db.phoneNumbers", () => {
175
180
  });
176
181
  });
177
182
 
178
- it("returns an empty array when the client has no phone numbers", async () => {
183
+ it("should return an empty array when the client has no phone numbers", async () => {
179
184
  const result = await getPhoneNumbersForFlows("noSuchClient");
180
185
  expect(result).toEqual([]);
181
186
  });
182
187
 
183
- it("does not include phone numbers for other clients", async () => {
188
+ it("should not include phone numbers for other clients", async () => {
184
189
  const clientA = "clientA";
185
190
  const clientB = "clientB";
186
191
  const flow = createFlow();
@@ -197,7 +202,7 @@ describe("db.phoneNumbers", () => {
197
202
  });
198
203
 
199
204
  describe("createPhoneNumberEntity", () => {
200
- it("creates first phone number as primary", async () => {
205
+ it("should create first phone number as primary", async () => {
201
206
  const clientId = "newClient";
202
207
  const flowId = new ObjectId().toHexString();
203
208
  const phoneNumber = "+123456789";
@@ -211,7 +216,7 @@ describe("db.phoneNumbers", () => {
211
216
  expect(result.is_primary).toBe(true);
212
217
  });
213
218
 
214
- it("creates subsequent phone number as non-primary", async () => {
219
+ it("should create subsequent phone number as non-primary", async () => {
215
220
  const clientId = "existingClient";
216
221
  const flowId = new ObjectId().toHexString();
217
222
 
@@ -226,7 +231,7 @@ describe("db.phoneNumbers", () => {
226
231
  });
227
232
 
228
233
  describe("createPurchasedPhoneNumber", () => {
229
- it("persists a phone_numbers document with provider payload and flow", async () => {
234
+ it("should persist a phone_numbers document with provider payload and flow", async () => {
230
235
  const clientId = "purchasedClientDb";
231
236
  const flowId = new ObjectId().toHexString();
232
237
  const phoneNumber = "+15551234567";
@@ -258,7 +263,7 @@ describe("db.phoneNumbers", () => {
258
263
  });
259
264
 
260
265
  describe("getPrimaryPhoneFlowId", () => {
261
- it("returns flow_id from primary phone as string", async () => {
266
+ it("should return flow_id from primary phone as string", async () => {
262
267
  const clientId = "primaryFlowClient";
263
268
  const flowId = new ObjectId();
264
269
  await getPhoneNumbersCollection().insertOne(
@@ -272,11 +277,11 @@ describe("db.phoneNumbers", () => {
272
277
  expect(await getPrimaryPhoneFlowId(clientId)).toBe(flowId.toHexString());
273
278
  });
274
279
 
275
- it("returns null when client has no primary phone", async () => {
280
+ it("should return null when client has no primary phone", async () => {
276
281
  expect(await getPrimaryPhoneFlowId("no-phone-client")).toBeNull();
277
282
  });
278
283
 
279
- it("returns null when primary phone has no flow_id", async () => {
284
+ it("should return null when primary phone has no flow_id", async () => {
280
285
  const clientId = "phoneNoFlow";
281
286
  const phone = createPhoneNumber({
282
287
  client_id: clientId,
@@ -289,6 +294,272 @@ describe("db.phoneNumbers", () => {
289
294
  });
290
295
  });
291
296
 
297
+ describe("getClientPhones", () => {
298
+ it("should return all phone numbers for the given client, projected to phoneNumber only", async () => {
299
+ const clientId = "phonesClientA";
300
+ await getPhoneNumbersCollection().insertMany([
301
+ createPhoneNumber({ client_id: clientId, phone_number: "+100" }),
302
+ createPhoneNumber({ client_id: clientId, phone_number: "+200" }),
303
+ ]);
304
+
305
+ const result = await getClientPhones(clientId);
306
+
307
+ expect(result).toHaveLength(2);
308
+ expect(result).toEqual(
309
+ expect.arrayContaining([
310
+ { phoneNumber: "+100" },
311
+ { phoneNumber: "+200" },
312
+ ]),
313
+ );
314
+ });
315
+
316
+ it("should not include phone numbers belonging to other clients", async () => {
317
+ await getPhoneNumbersCollection().insertOne(
318
+ createPhoneNumber({ client_id: "someOtherClient" }),
319
+ );
320
+
321
+ const result = await getClientPhones("phonesClientB");
322
+ expect(result).toEqual([]);
323
+ });
324
+
325
+ it("should remove exact duplicate phone records for the same client", async () => {
326
+ const clientId = "phonesClientDup";
327
+ await getPhoneNumbersCollection().insertMany([
328
+ createPhoneNumber({ client_id: clientId, phone_number: "+300" }),
329
+ createPhoneNumber({ client_id: clientId, phone_number: "+300" }),
330
+ createPhoneNumber({ client_id: clientId, phone_number: "+400" }),
331
+ ]);
332
+
333
+ const result = await getClientPhones(clientId);
334
+
335
+ expect(result).toHaveLength(2);
336
+ expect(result).toEqual(
337
+ expect.arrayContaining([
338
+ { phoneNumber: "+300" },
339
+ { phoneNumber: "+400" },
340
+ ]),
341
+ );
342
+ });
343
+ });
344
+
345
+ describe("findPhonesByClientIds", () => {
346
+ it("should return a Map of clientId to phone number for the requested clients only", async () => {
347
+ await getPhoneNumbersCollection().insertMany([
348
+ createPhoneNumber({ client_id: "mapClientA", phone_number: "+111" }),
349
+ createPhoneNumber({ client_id: "mapClientB", phone_number: "+222" }),
350
+ createPhoneNumber({ client_id: "mapClientC", phone_number: "+333" }),
351
+ ]);
352
+
353
+ const result = await findPhonesByClientIds(["mapClientA", "mapClientB"]);
354
+
355
+ expect(result).toBeInstanceOf(Map);
356
+ expect(result.get("mapClientA")).toBe("+111");
357
+ expect(result.get("mapClientB")).toBe("+222");
358
+ expect(result.has("mapClientC")).toBe(false);
359
+ });
360
+
361
+ it("should return an empty Map for an empty list of clients", async () => {
362
+ await getPhoneNumbersCollection().insertOne(
363
+ createPhoneNumber({ client_id: "mapClientA", phone_number: "+111" }),
364
+ );
365
+
366
+ const result = await findPhonesByClientIds([]);
367
+
368
+ expect(result.size).toBe(0);
369
+ });
370
+
371
+ it("should skip clients that have no phone number", async () => {
372
+ const result = await findPhonesByClientIds(["clientWithoutPhone"]);
373
+ expect(result.size).toBe(0);
374
+ });
375
+
376
+ it("should return the most recently updated phone number when a client has several", async () => {
377
+ const clientId = "mapClientRecent";
378
+ await getPhoneNumbersCollection().insertMany([
379
+ createPhoneNumber({
380
+ client_id: clientId,
381
+ phone_number: "+old",
382
+ updatedAt: new Date("2023-01-01"),
383
+ }),
384
+ createPhoneNumber({
385
+ client_id: clientId,
386
+ phone_number: "+new",
387
+ updatedAt: new Date("2023-06-01"),
388
+ }),
389
+ ]);
390
+
391
+ const result = await findPhonesByClientIds([clientId]);
392
+
393
+ expect(result.get(clientId)).toBe("+new");
394
+ });
395
+
396
+ it("should prefer the is_primary number over a more recently updated non-primary one", async () => {
397
+ const clientId = "mapClientPrimary";
398
+ await getPhoneNumbersCollection().insertMany([
399
+ createPhoneNumber({
400
+ client_id: clientId,
401
+ phone_number: "+primary",
402
+ is_primary: true,
403
+ updatedAt: new Date("2023-01-01"),
404
+ }),
405
+ createPhoneNumber({
406
+ client_id: clientId,
407
+ phone_number: "+secondary",
408
+ is_primary: false,
409
+ updatedAt: new Date("2023-06-01"),
410
+ }),
411
+ ]);
412
+
413
+ const result = await findPhonesByClientIds([clientId]);
414
+
415
+ expect(result.get(clientId)).toBe("+primary");
416
+ });
417
+ });
418
+
419
+ describe("getDistinctClientPhonePairs", () => {
420
+ it("should return unique client+phone pairs, collapsing exact duplicates", async () => {
421
+ await getPhoneNumbersCollection().insertMany([
422
+ createPhoneNumber({ client_id: "pairClientA", phone_number: "+500" }),
423
+ createPhoneNumber({ client_id: "pairClientA", phone_number: "+500" }),
424
+ createPhoneNumber({ client_id: "pairClientB", phone_number: "+600" }),
425
+ ]);
426
+
427
+ const result = await getDistinctClientPhonePairs();
428
+
429
+ expect(result).toEqual(
430
+ expect.arrayContaining([
431
+ { clientId: "pairClientA", phoneNumber: "+500", isPrimary: true },
432
+ { clientId: "pairClientB", phoneNumber: "+600", isPrimary: true },
433
+ ]),
434
+ );
435
+ expect(
436
+ result.filter(
437
+ (p) => p.clientId === "pairClientA" && p.phoneNumber === "+500",
438
+ ),
439
+ ).toHaveLength(1);
440
+ });
441
+
442
+ it("should keep separate pairs when the same number is shared by different clients", async () => {
443
+ await getPhoneNumbersCollection().insertMany([
444
+ createPhoneNumber({ client_id: "sharedClientA", phone_number: "+700" }),
445
+ createPhoneNumber({ client_id: "sharedClientB", phone_number: "+700" }),
446
+ ]);
447
+
448
+ const result = await getDistinctClientPhonePairs();
449
+
450
+ expect(result).toEqual(
451
+ expect.arrayContaining([
452
+ { clientId: "sharedClientA", phoneNumber: "+700", isPrimary: true },
453
+ { clientId: "sharedClientB", phoneNumber: "+700", isPrimary: true },
454
+ ]),
455
+ );
456
+ });
457
+
458
+ it("should report isPrimary: false for a non-primary number", async () => {
459
+ await getPhoneNumbersCollection().insertMany([
460
+ createPhoneNumber({ client_id: "mixedClient", phone_number: "+900", is_primary: true }),
461
+ createPhoneNumber({ client_id: "mixedClient", phone_number: "+901", is_primary: false }),
462
+ ]);
463
+
464
+ const result = await getDistinctClientPhonePairs();
465
+
466
+ expect(result).toEqual(
467
+ expect.arrayContaining([
468
+ { clientId: "mixedClient", phoneNumber: "+900", isPrimary: true },
469
+ { clientId: "mixedClient", phoneNumber: "+901", isPrimary: false },
470
+ ]),
471
+ );
472
+ });
473
+
474
+ it("should report isPrimary: false when no phone_numbers document for that client is marked primary", async () => {
475
+ await getPhoneNumbersCollection().insertOne(
476
+ createPhoneNumber({ client_id: "noPrimaryClient", phone_number: "+902", is_primary: false }),
477
+ );
478
+
479
+ const result = await getDistinctClientPhonePairs();
480
+
481
+ expect(result).toContainEqual({
482
+ clientId: "noPrimaryClient",
483
+ phoneNumber: "+902",
484
+ isPrimary: false,
485
+ });
486
+ });
487
+ });
488
+
489
+ describe("getDistinctClientPhonePairs with clientIds", () => {
490
+ it("should return only the pairs of the given clients", async () => {
491
+ await getPhoneNumbersCollection().insertMany([
492
+ createPhoneNumber({ client_id: "wantedPairClient", phone_number: "+111" }),
493
+ createPhoneNumber({ client_id: "otherPairClient", phone_number: "+222" }),
494
+ ]);
495
+
496
+ const result = await getDistinctClientPhonePairs(["wantedPairClient"]);
497
+
498
+ expect(result).toEqual([
499
+ { clientId: "wantedPairClient", phoneNumber: "+111", isPrimary: true },
500
+ ]);
501
+ });
502
+ });
503
+
504
+ describe("findClientsByPhoneNumbers", () => {
505
+ it("should return other clients sharing each phone number, excluding the given client", async () => {
506
+ await getPhoneNumbersCollection().insertMany([
507
+ createPhoneNumber({ client_id: "excludeMe", phone_number: "+800" }),
508
+ createPhoneNumber({ client_id: "otherClient1", phone_number: "+800" }),
509
+ createPhoneNumber({ client_id: "otherClient2", phone_number: "+800" }),
510
+ ]);
511
+
512
+ const result = await findClientsByPhoneNumbers(["+800"], "excludeMe");
513
+
514
+ expect(result.get("+800")).toEqual(
515
+ expect.arrayContaining(["otherClient1", "otherClient2"]),
516
+ );
517
+ expect(result.get("+800")).not.toContain("excludeMe");
518
+ });
519
+
520
+ it("should return an empty array for a phone number with no other clients", async () => {
521
+ await getPhoneNumbersCollection().insertOne(
522
+ createPhoneNumber({ client_id: "soleOwner", phone_number: "+900" }),
523
+ );
524
+
525
+ const result = await findClientsByPhoneNumbers(["+900"], "soleOwner");
526
+
527
+ expect(result.get("+900")).toEqual([]);
528
+ });
529
+
530
+ it("should return an empty map immediately when given no phone numbers", async () => {
531
+ const result = await findClientsByPhoneNumbers([]);
532
+ expect(result.size).toBe(0);
533
+ });
534
+ });
535
+
536
+ describe("deletePhoneNumberRecords", () => {
537
+ it("should delete every record for the given phone number across all clients", async () => {
538
+ await getPhoneNumbersCollection().insertMany([
539
+ createPhoneNumber({ client_id: "delClientA", phone_number: "+1000" }),
540
+ createPhoneNumber({ client_id: "delClientB", phone_number: "+1000" }),
541
+ createPhoneNumber({ client_id: "delClientC", phone_number: "+2000" }),
542
+ ]);
543
+
544
+ const deletedCount = await deletePhoneNumberRecords("+1000");
545
+
546
+ expect(deletedCount).toBe(2);
547
+ const remaining = await getPhoneNumbersCollection()
548
+ .find({ phone_number: "+1000" })
549
+ .toArray();
550
+ expect(remaining).toHaveLength(0);
551
+ const untouched = await getPhoneNumbersCollection().findOne({
552
+ phone_number: "+2000",
553
+ });
554
+ expect(untouched?.client_id).toBe("delClientC");
555
+ });
556
+
557
+ it("should return 0 when no records match the phone number", async () => {
558
+ const deletedCount = await deletePhoneNumberRecords("+9999999");
559
+ expect(deletedCount).toBe(0);
560
+ });
561
+ });
562
+
292
563
  describe("findPhoneNumbersByClientId", () => {
293
564
  it("returns only that client's numbers", async () => {
294
565
  const clientId = "list-client";
@@ -9,6 +9,7 @@ import {
9
9
  } from "../index";
10
10
  import {
11
11
  ClientPhoneNumberForFlow,
12
+ ClientPhonePair,
12
13
  PhoneNumber,
13
14
  PhoneNumberWithFlow,
14
15
  PurchasedPhoneProviderPayload,
@@ -158,7 +159,9 @@ export const findClientByPhoneNumber = async (
158
159
  return client;
159
160
  };
160
161
 
161
- export const getPrimaryPhoneFlowId = async (clientId: string): Promise<string | null> => {
162
+ export const getPrimaryPhoneFlowId = async (
163
+ clientId: string,
164
+ ): Promise<string | null> => {
162
165
  const phone = await getClientPhoneData(clientId);
163
166
  return phone?.flow_id ? String(phone.flow_id) : null;
164
167
  };
@@ -176,6 +179,91 @@ export const bindClientPhoneToFlow = async (
176
179
  }
177
180
  };
178
181
 
182
+ export const getClientPhones = async (
183
+ clientId: string,
184
+ ): Promise<{ phoneNumber: string }[]> => {
185
+ const docs = await getPhoneNumbersCollection()
186
+ .aggregate<{ _id: string }>([
187
+ { $match: { client_id: clientId } },
188
+ { $group: { _id: "$phone_number" } },
189
+ ])
190
+ .toArray();
191
+ return docs.map((d) => ({ phoneNumber: d._id }));
192
+ };
193
+
194
+ export const findPhonesByClientIds = async (
195
+ clientIds: string[],
196
+ ): Promise<Map<string, string>> => {
197
+ if (clientIds.length === 0) return new Map();
198
+
199
+ const docs = await getPhoneNumbersCollection()
200
+ .aggregate<{ _id: string; phone_number: string }>([
201
+ { $match: { client_id: { $in: clientIds } } },
202
+ { $sort: { is_primary: -1, updatedAt: -1 } },
203
+ { $group: { _id: "$client_id", phone_number: { $first: "$phone_number" } } },
204
+ ])
205
+ .toArray();
206
+ return new Map(docs.map((d) => [d._id, d.phone_number]));
207
+ };
208
+
209
+ export const getDistinctClientPhonePairs = async (
210
+ clientIds?: string[],
211
+ ): Promise<ClientPhonePair[]> => {
212
+ const docs = await getPhoneNumbersCollection()
213
+ .aggregate<{
214
+ _id: { client_id: string; phone_number: string };
215
+ isPrimary: boolean;
216
+ }>([
217
+ ...(clientIds ? [{ $match: { client_id: { $in: clientIds } } }] : []),
218
+ {
219
+ $group: {
220
+ _id: { client_id: "$client_id", phone_number: "$phone_number" },
221
+ isPrimary: { $max: "$is_primary" },
222
+ },
223
+ },
224
+ ])
225
+ .toArray();
226
+ return docs.map((d) => ({
227
+ clientId: d._id.client_id,
228
+ phoneNumber: d._id.phone_number,
229
+ isPrimary: Boolean(d.isPrimary),
230
+ }));
231
+ };
232
+
233
+ export const findClientsByPhoneNumbers = async (
234
+ phoneNumbers: string[],
235
+ excludeClientId?: string,
236
+ ): Promise<Map<string, string[]>> => {
237
+ const result = new Map<string, string[]>(
238
+ phoneNumbers.map((p) => [p, [] as string[]]),
239
+ );
240
+ if (phoneNumbers.length === 0) return result;
241
+
242
+ const docs = await getPhoneNumbersCollection()
243
+ .aggregate<{ _id: string; clientIds: string[] }>([
244
+ {
245
+ $match: {
246
+ phone_number: { $in: phoneNumbers },
247
+ ...(excludeClientId ? { client_id: { $ne: excludeClientId } } : {}),
248
+ },
249
+ },
250
+ { $group: { _id: "$phone_number", clientIds: { $addToSet: "$client_id" } } },
251
+ ])
252
+ .toArray();
253
+
254
+ docs.forEach((d) => result.set(d._id, d.clientIds));
255
+ return result;
256
+ };
257
+
258
+ /** Deletes this number's record for every client that references it. Check `findClientsByPhoneNumbers` first to see who's affected. */
259
+ export const deletePhoneNumberRecords = async (
260
+ phoneNumber: string,
261
+ ): Promise<number> => {
262
+ const result = await getPhoneNumbersCollection().deleteMany({
263
+ phone_number: phoneNumber,
264
+ });
265
+ return result.deletedCount;
266
+ };
179
267
  export const findPhoneNumbersByClientId = (
180
268
  clientId: string,
181
269
  ): Promise<PhoneNumber[]> =>
@@ -28,3 +28,9 @@ export type ClientPhoneNumberForFlow = {
28
28
  phoneNumber: string;
29
29
  isPrimary: boolean;
30
30
  };
31
+
32
+ export type ClientPhonePair = {
33
+ clientId: string;
34
+ phoneNumber: string;
35
+ isPrimary: boolean;
36
+ };
@@ -1,4 +1,5 @@
1
1
  export * from "./talkpilot/agents";
2
+ export * from "./talkpilot/clients";
2
3
  export * from "./talkpilot/calls";
3
4
  export * from "./talkpilot/clientAudioBuffers";
4
5
  export * from "./talkpilot/clientsConfig";
@@ -7,6 +8,7 @@ export * from "./talkpilot/contextNotes";
7
8
  export * from "./talkpilot/groups";
8
9
  export * from "./talkpilot/phone_numbers";
9
10
  export * from "./talkpilot/sessions";
11
+ export * from "./talkpilot/results";
10
12
  export * from "./municipal/cities";
11
13
  export * from "./municipal/departmentsSubjects";
12
14
  export * from "./municipal/muniIssues";
@@ -0,0 +1,21 @@
1
+ import { Factory } from "fishery";
2
+ import { ObjectId } from "mongodb";
3
+ import { faker } from "@faker-js/faker";
4
+ import type { Client } from "../../../talkpilot";
5
+
6
+ export const clientFactory = Factory.define<Client & { _id?: ObjectId }>(
7
+ () => ({
8
+ _id: new ObjectId(),
9
+ clientId: faker.string.uuid(),
10
+ isActive: true,
11
+ credits: faker.number.int({ min: 0, max: 1000 }),
12
+ createdAt: new Date(),
13
+ updatedAt: new Date(),
14
+ }),
15
+ );
16
+
17
+ export function createClient(
18
+ overrides?: Partial<Client>,
19
+ ): Client & { _id?: ObjectId } {
20
+ return clientFactory.build(overrides);
21
+ }