@talkpilot/core-db 1.3.88 → 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
@@ -4,22 +4,39 @@ import {
4
4
  CallQueryOptions,
5
5
  CallUpdateParams,
6
6
  getDb,
7
+ getClientsCollection,
7
8
  } from "../index";
8
9
  import type {
9
10
  CountOpts,
10
11
  DateRange,
11
12
  ToolExecution,
12
13
  CallStatus,
14
+ ActiveCallsSummary,
15
+ TodayCallsSummary,
16
+ ClientCallStatsPage,
17
+ ClientCallStatsParams,
18
+ SessionCallStats,
19
+ StatsDateRangeParams,
13
20
  CallEndedBy,
14
21
  } from "./calls.types";
15
22
  import {
16
23
  TOOL_EXECUTIONS_DEFAULT_LIMIT,
17
24
  TOOL_EXECUTIONS_DEFAULT_SKIP,
25
+ ACTIVE_STATUSES,
26
+ ACTIVE_CALLS_WINDOW_MS,
27
+ INCOMING_SESSION_ID,
28
+ UNKNOWN_SESSION_ID,
29
+ ALL_STATUSES_FOR_BREAKDOWN,
18
30
  } from "./calls.constants";
19
31
  import { Filter, ObjectId } from "mongodb";
20
32
  import * as process from "node:process";
21
33
  import { applyQueryOptions } from "../utils/query.utils";
22
34
  import { DashboardHeatmapMetric } from "./dashboard/calls.dashboard.types";
35
+ import {
36
+ formatYmdInTz,
37
+ startOfCalendarDayInTz,
38
+ endOfCalendarDayInTz,
39
+ } from "../../utils/date.utils";
23
40
 
24
41
  export const getCallsCollection = () => {
25
42
  return getDb().collection<Call>("calls");
@@ -293,3 +310,306 @@ export async function getCallsHourlyAggregation(
293
310
  calls: r.calls,
294
311
  }));
295
312
  }
313
+
314
+ type StatusCountFields = { [K in (typeof ALL_STATUSES_FOR_BREAKDOWN)[number]]: number };
315
+
316
+ // One $sum/$cond accumulator per status in ALL_STATUSES_FOR_BREAKDOWN.
317
+ const buildStatusCountFields = (field: string) =>
318
+ Object.fromEntries(
319
+ ALL_STATUSES_FOR_BREAKDOWN.map((status) => [
320
+ status,
321
+ { $sum: { $cond: [{ $eq: [field, status] }, 1, 0] } },
322
+ ]),
323
+ );
324
+
325
+ const extractStatusCounts = (row: StatusCountFields): Record<string, number> =>
326
+ Object.fromEntries(ALL_STATUSES_FOR_BREAKDOWN.map((status) => [status, row[status] ?? 0]));
327
+
328
+ const dateRangeFilter = (
329
+ dateFrom: string | undefined,
330
+ dateTo: string | undefined,
331
+ timezone: string,
332
+ ): { start: Date; end: Date } => {
333
+ const today = formatYmdInTz(new Date(), timezone);
334
+ return {
335
+ start: startOfCalendarDayInTz(dateFrom ?? today, timezone),
336
+ end: endOfCalendarDayInTz(dateTo ?? today, timezone),
337
+ };
338
+ };
339
+
340
+ const buildCallsStatsMatch = ({
341
+ dateFrom,
342
+ dateTo,
343
+ clientId,
344
+ clientIds,
345
+ timezone = "UTC",
346
+ }: StatsDateRangeParams) => {
347
+ const range = dateFrom || dateTo ? dateRangeFilter(dateFrom, dateTo, timezone) : null;
348
+ const clientConditions = [
349
+ ...(clientId ? [{ clientId }] : []),
350
+ ...(clientIds ? [{ clientId: { $in: clientIds } }] : []),
351
+ ];
352
+ return {
353
+ ...(range ? { createdAt: { $gte: range.start, $lte: range.end } } : {}),
354
+ ...(clientConditions.length > 0 ? { $and: clientConditions } : {}),
355
+ };
356
+ };
357
+
358
+ export const findClientIdsWithCalls = async ({
359
+ dateFrom,
360
+ dateTo,
361
+ timezone = "UTC",
362
+ }: StatsDateRangeParams): Promise<string[]> =>
363
+ getCallsCollection().distinct("clientId", buildCallsStatsMatch({ dateFrom, dateTo, timezone }));
364
+
365
+ export const aggregateActiveCallsSummary = async (
366
+ clientId?: string,
367
+ ): Promise<ActiveCallsSummary> => {
368
+ const coll = getCallsCollection();
369
+ const twoHoursAgo = new Date(Date.now() - ACTIVE_CALLS_WINDOW_MS);
370
+
371
+ const match = {
372
+ status: { $in: ACTIVE_STATUSES },
373
+ updatedAt: { $gte: twoHoursAgo },
374
+ ...(clientId ? { clientId } : {}),
375
+ };
376
+
377
+ const facetResult = await coll
378
+ .aggregate<{
379
+ counts: { total: number; incoming: number; outgoing: number }[];
380
+ activeClients: { _id: string }[];
381
+ }>([
382
+ { $match: match },
383
+ {
384
+ $facet: {
385
+ counts: [
386
+ {
387
+ $group: {
388
+ _id: null,
389
+ total: { $sum: 1 },
390
+ incoming: { $sum: { $cond: ["$isIncomingCall", 1, 0] } },
391
+ outgoing: { $sum: { $cond: ["$isOutgoingCall", 1, 0] } },
392
+ },
393
+ },
394
+ ],
395
+ activeClients: [{ $group: { _id: "$clientId" } }],
396
+ },
397
+ },
398
+ ])
399
+ .next();
400
+
401
+ const counts = facetResult?.counts?.[0];
402
+
403
+ return {
404
+ total: counts?.total ?? 0,
405
+ incoming: counts?.incoming ?? 0,
406
+ outgoing: counts?.outgoing ?? 0,
407
+ activeClientIds: (facetResult?.activeClients ?? []).map((d) => d._id || "Unknown"),
408
+ };
409
+ };
410
+
411
+ export const aggregateTodayCallsSummary = async ({
412
+ dateFrom,
413
+ dateTo,
414
+ clientId,
415
+ timezone = "UTC",
416
+ }: StatsDateRangeParams): Promise<TodayCallsSummary> => {
417
+ const coll = getCallsCollection();
418
+ const match = buildCallsStatsMatch({ dateFrom, dateTo, clientId, timezone });
419
+
420
+ const facetResult = await coll
421
+ .aggregate<{
422
+ byStatus: { _id: string; count: number }[];
423
+ byType: { _id: string; count: number }[];
424
+ total: { count: number }[];
425
+ totalSeconds: { seconds: number }[];
426
+ }>([
427
+ { $match: match },
428
+ {
429
+ $facet: {
430
+ byStatus: [{ $group: { _id: "$status", count: { $sum: 1 } } }],
431
+ byType: [
432
+ {
433
+ $group: {
434
+ _id: { $cond: ["$isIncomingCall", "incoming", "outgoing"] },
435
+ count: { $sum: 1 },
436
+ },
437
+ },
438
+ ],
439
+ total: [{ $group: { _id: null, count: { $sum: 1 } } }],
440
+ totalSeconds: [{ $group: { _id: null, seconds: { $sum: "$callLength" } } }],
441
+ },
442
+ },
443
+ ])
444
+ .next();
445
+
446
+ const byStatus: Record<string, number> = {};
447
+ (facetResult?.byStatus ?? []).forEach((s) => {
448
+ byStatus[s._id] = s.count;
449
+ });
450
+ const byType: Record<string, number> = {};
451
+ (facetResult?.byType ?? []).forEach((t) => {
452
+ byType[t._id] = t.count;
453
+ });
454
+
455
+ const totalSeconds = facetResult?.totalSeconds?.[0]?.seconds ?? 0;
456
+
457
+ return {
458
+ total: facetResult?.total?.[0]?.count ?? 0,
459
+ incoming: byType.incoming ?? 0,
460
+ outgoing: byType.outgoing ?? 0,
461
+ completed: byStatus.completed ?? 0,
462
+ failed: byStatus.failed ?? 0,
463
+ busy: byStatus.busy ?? 0,
464
+ noAnswer: byStatus["no-answer"] ?? 0,
465
+ totalMinutes: Math.ceil(totalSeconds / 60),
466
+ };
467
+ };
468
+
469
+ export const aggregateClientCallStats = async ({
470
+ dateFrom,
471
+ dateTo,
472
+ clientId,
473
+ clientIds,
474
+ timezone = "UTC",
475
+ skip = 0,
476
+ limit,
477
+ }: ClientCallStatsParams): Promise<ClientCallStatsPage> => {
478
+ const [result] = await getCallsCollection()
479
+ .aggregate<{
480
+ rows: ({
481
+ _id: string;
482
+ totalCalls: number;
483
+ totalSeconds: number;
484
+ lastCallDate: Date;
485
+ } & StatusCountFields)[];
486
+ total: { count: number }[];
487
+ }>([
488
+ { $match: buildCallsStatsMatch({ dateFrom, dateTo, clientId, clientIds, timezone }) },
489
+ {
490
+ $group: {
491
+ _id: "$clientId",
492
+ totalCalls: { $sum: 1 },
493
+ ...buildStatusCountFields("$status"),
494
+ totalSeconds: { $sum: "$callLength" },
495
+ lastCallDate: { $max: "$createdAt" },
496
+ },
497
+ },
498
+ {
499
+ $lookup: {
500
+ from: getClientsCollection().collectionName,
501
+ localField: "_id",
502
+ foreignField: "clientId",
503
+ as: "client",
504
+ },
505
+ },
506
+ { $match: { "client.0": { $exists: true } } },
507
+ { $project: { client: 0 } },
508
+ { $sort: { totalCalls: -1, _id: 1 } },
509
+ {
510
+ $facet: {
511
+ rows: [{ $skip: skip }, ...(limit ? [{ $limit: limit }] : [])],
512
+ total: [{ $count: "count" }],
513
+ },
514
+ },
515
+ ])
516
+ .toArray();
517
+
518
+ return {
519
+ rows: (result?.rows ?? []).map((r) => ({
520
+ clientId: r._id,
521
+ totalCalls: r.totalCalls,
522
+ statusCounts: extractStatusCounts(r),
523
+ totalMinutes: Math.ceil((r.totalSeconds || 0) / 60),
524
+ lastCallDate: r.lastCallDate,
525
+ })),
526
+ total: result?.total[0]?.count ?? 0,
527
+ };
528
+ };
529
+
530
+ export const aggregateSessionCallStats = async ({
531
+ dateFrom,
532
+ dateTo,
533
+ clientId,
534
+ timezone = "UTC",
535
+ }: StatsDateRangeParams): Promise<SessionCallStats[]> => {
536
+ const coll = getCallsCollection();
537
+ const match = buildCallsStatsMatch({ dateFrom, dateTo, clientId, timezone });
538
+
539
+ const sessionGroupKey = {
540
+ $cond: [
541
+ { $eq: ["$isIncomingCall", true] },
542
+ INCOMING_SESSION_ID,
543
+ { $ifNull: ["$sessionId", UNKNOWN_SESSION_ID] },
544
+ ],
545
+ };
546
+
547
+ const rows = await coll
548
+ .aggregate<
549
+ {
550
+ _id: ObjectId | string;
551
+ sessionDoc: { session_name: string }[];
552
+ totalCalls: number;
553
+ } & StatusCountFields
554
+ >([
555
+ { $match: match },
556
+ {
557
+ $group: {
558
+ _id: sessionGroupKey,
559
+ totalCalls: { $sum: 1 },
560
+ ...buildStatusCountFields("$status"),
561
+ },
562
+ },
563
+ {
564
+ $lookup: {
565
+ from: "sessions",
566
+ localField: "_id",
567
+ foreignField: "_id",
568
+ as: "sessionDoc",
569
+ },
570
+ },
571
+ { $sort: { totalCalls: -1 } },
572
+ ])
573
+ .toArray();
574
+
575
+ return rows.map((r) => ({
576
+ sessionId: r._id.toString(),
577
+ // No session, or a deleted/unnamed one, has no name to look up: that is "Unknown".
578
+ sessionName:
579
+ r._id === INCOMING_SESSION_ID
580
+ ? "Incoming Call"
581
+ : r.sessionDoc[0]?.session_name || "Unknown",
582
+ totalCalls: r.totalCalls,
583
+ statusCounts: extractStatusCounts(r),
584
+ }));
585
+ };
586
+
587
+ export const findLastCallDatesByClientIds = async (
588
+ clientIds: string[],
589
+ ): Promise<Map<string, Date>> => {
590
+ if (clientIds.length === 0) return new Map();
591
+
592
+ const rows = await getCallsCollection()
593
+ .aggregate<{ _id: string; lastCallDate: Date }>([
594
+ { $match: { clientId: { $in: clientIds } } },
595
+ { $group: { _id: "$clientId", lastCallDate: { $max: "$createdAt" } } },
596
+ ])
597
+ .toArray();
598
+
599
+ return new Map(rows.map((r) => [r._id, r.lastCallDate]));
600
+ };
601
+
602
+ export const findClientIdsByCallSids = async (
603
+ callSids: string[],
604
+ ): Promise<Map<string, string>> => {
605
+ if (callSids.length === 0) return new Map();
606
+
607
+ const docs = await getCallsCollection()
608
+ .find(
609
+ { callSid: { $in: callSids } },
610
+ { projection: { _id: 0, clientId: 1, callSid: 1 } },
611
+ )
612
+ .toArray();
613
+
614
+ return new Map(docs.map((d) => [d.callSid, d.clientId]));
615
+ };
@@ -166,3 +166,55 @@ export type DateRange = {
166
166
  since?: Date;
167
167
  until?: Date;
168
168
  };
169
+
170
+ export type StatsDateRangeParams = {
171
+ dateFrom?: string;
172
+ dateTo?: string;
173
+ clientId?: string;
174
+ /** Restricts to these clients. Combined with clientId, both must match. */
175
+ clientIds?: string[];
176
+ timezone?: string;
177
+ };
178
+
179
+ export type ClientCallStatsParams = StatsDateRangeParams & {
180
+ skip?: number;
181
+ limit?: number;
182
+ };
183
+
184
+ export type ActiveCallsSummary = {
185
+ total: number;
186
+ incoming: number;
187
+ outgoing: number;
188
+ activeClientIds: string[];
189
+ };
190
+
191
+ export type TodayCallsSummary = {
192
+ total: number;
193
+ incoming: number;
194
+ outgoing: number;
195
+ completed: number;
196
+ failed: number;
197
+ busy: number;
198
+ noAnswer: number;
199
+ totalMinutes: number;
200
+ };
201
+
202
+ export type ClientCallStats = {
203
+ clientId: string;
204
+ totalCalls: number;
205
+ statusCounts: Record<string, number>;
206
+ totalMinutes: number;
207
+ lastCallDate: Date;
208
+ };
209
+
210
+ export type ClientCallStatsPage = {
211
+ rows: ClientCallStats[];
212
+ total: number;
213
+ };
214
+
215
+ export type SessionCallStats = {
216
+ sessionId: string;
217
+ sessionName: string;
218
+ totalCalls: number;
219
+ statusCounts: Record<string, number>;
220
+ };
@@ -0,0 +1,224 @@
1
+ import {
2
+ getClientsCollection,
3
+ getAllClientIds,
4
+ getBackofficeRole,
5
+ setBackofficeRole,
6
+ countBackofficeAdmins,
7
+ findClientsPage,
8
+ } from "../clients.getters";
9
+ import { getPhoneNumbersCollection } from "../../phone_numbers/phone_numbers.getter";
10
+ import { createClient, createPhoneNumber } from "../../../test-utils/factories";
11
+
12
+ describe("db.clients", () => {
13
+ describe("getAllClientIds", () => {
14
+ it("should return the ids of all clients", async () => {
15
+ await getClientsCollection().insertMany([
16
+ createClient({ clientId: "idClientA" }),
17
+ createClient({ clientId: "idClientB" }),
18
+ ]);
19
+
20
+ const result = await getAllClientIds();
21
+
22
+ expect(result.sort()).toEqual(["idClientA", "idClientB"]);
23
+ });
24
+
25
+ it("should return an empty array when there are no clients", async () => {
26
+ const result = await getAllClientIds();
27
+ expect(result).toEqual([]);
28
+ });
29
+ });
30
+
31
+ describe("getBackofficeRole", () => {
32
+ it("returns 'admin' when backoffice.admin is set", async () => {
33
+ await getClientsCollection().insertOne(
34
+ createClient({
35
+ clientId: "roleAdmin",
36
+ roles: { backoffice: { admin: true } },
37
+ }),
38
+ );
39
+ expect(await getBackofficeRole("roleAdmin")).toBe("admin");
40
+ });
41
+
42
+ it("returns 'viewer' when only backoffice.viewer is set", async () => {
43
+ await getClientsCollection().insertOne(
44
+ createClient({
45
+ clientId: "roleViewer",
46
+ roles: { backoffice: { viewer: true } },
47
+ }),
48
+ );
49
+ expect(await getBackofficeRole("roleViewer")).toBe("viewer");
50
+ });
51
+
52
+ it("lets admin win when both flags are set", async () => {
53
+ await getClientsCollection().insertOne(
54
+ createClient({
55
+ clientId: "roleBoth",
56
+ roles: { backoffice: { admin: true, viewer: true } },
57
+ }),
58
+ );
59
+ expect(await getBackofficeRole("roleBoth")).toBe("admin");
60
+ });
61
+
62
+ it("ignores the legacy roles.admin flag", async () => {
63
+ await getClientsCollection().insertOne(
64
+ createClient({ clientId: "legacyAdmin", roles: { admin: true } }),
65
+ );
66
+ expect(await getBackofficeRole("legacyAdmin")).toBeNull();
67
+ });
68
+
69
+ it("returns null for false flags, a client without roles, and an unknown client", async () => {
70
+ await getClientsCollection().insertMany([
71
+ createClient({
72
+ clientId: "roleFalse",
73
+ roles: { backoffice: { admin: false, viewer: false } },
74
+ }),
75
+ createClient({ clientId: "noRoles" }),
76
+ ]);
77
+ expect(await getBackofficeRole("roleFalse")).toBeNull();
78
+ expect(await getBackofficeRole("noRoles")).toBeNull();
79
+ expect(await getBackofficeRole("missing")).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe("setBackofficeRole", () => {
84
+ it("sets one role, clears the other, and leaves roles.admin alone", async () => {
85
+ await getClientsCollection().insertOne(
86
+ createClient({
87
+ clientId: "switchRole",
88
+ roles: { admin: true, backoffice: { admin: true } },
89
+ }),
90
+ );
91
+
92
+ expect(await setBackofficeRole("switchRole", "viewer")).toBe(true);
93
+ expect(await getBackofficeRole("switchRole")).toBe("viewer");
94
+
95
+ await setBackofficeRole("switchRole", null);
96
+ expect(await getBackofficeRole("switchRole")).toBeNull();
97
+
98
+ const doc = await getClientsCollection().findOne({
99
+ clientId: "switchRole",
100
+ });
101
+ expect(doc?.roles?.admin).toBe(true);
102
+ });
103
+
104
+ it("returns false for an unknown client", async () => {
105
+ expect(await setBackofficeRole("missing", "admin")).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("countBackofficeAdmins", () => {
110
+ it("counts only clients with the backoffice admin flag", async () => {
111
+ await getClientsCollection().insertMany([
112
+ createClient({
113
+ clientId: "adminOne",
114
+ roles: { backoffice: { admin: true } },
115
+ }),
116
+ createClient({
117
+ clientId: "viewerOne",
118
+ roles: { backoffice: { viewer: true } },
119
+ }),
120
+ createClient({ clientId: "legacyOnly", roles: { admin: true } }),
121
+ ]);
122
+ expect(await countBackofficeAdmins()).toBe(1);
123
+ });
124
+ });
125
+
126
+ describe("findClientsPage", () => {
127
+ const insertClientsCreatedAt = (...clientIds: string[]) =>
128
+ getClientsCollection().insertMany(
129
+ clientIds.map((clientId, index) =>
130
+ createClient({
131
+ clientId,
132
+ createdAt: new Date(Date.UTC(2026, 0, index + 1)),
133
+ }),
134
+ ),
135
+ );
136
+
137
+ it("returns one page, newest client first, with the total across all pages", async () => {
138
+ await insertClientsCreatedAt("pageOldest", "pageMiddle", "pageNewest");
139
+
140
+ const firstPage = await findClientsPage({ skip: 0, limit: 2 });
141
+ const secondPage = await findClientsPage({ skip: 2, limit: 2 });
142
+
143
+ expect(firstPage.clients.map((c) => c.clientId)).toEqual([
144
+ "pageNewest",
145
+ "pageMiddle",
146
+ ]);
147
+ expect(firstPage.total).toBe(3);
148
+ expect(secondPage.clients.map((c) => c.clientId)).toEqual(["pageOldest"]);
149
+ expect(secondPage.total).toBe(3);
150
+ });
151
+
152
+ it("searches the clientId case-insensitively", async () => {
153
+ await insertClientsCreatedAt("SearchMe-1", "other-client");
154
+
155
+ const page = await findClientsPage({
156
+ skip: 0,
157
+ limit: 10,
158
+ search: "searchme",
159
+ });
160
+
161
+ expect(page.clients.map((c) => c.clientId)).toEqual(["SearchMe-1"]);
162
+ expect(page.total).toBe(1);
163
+ });
164
+
165
+ it("searches phone numbers, treating regex characters like + as plain text", async () => {
166
+ await insertClientsCreatedAt("phoneOwner", "noPhoneMatch");
167
+ await getPhoneNumbersCollection().insertMany([
168
+ createPhoneNumber({
169
+ client_id: "phoneOwner",
170
+ phone_number: "+97233823446",
171
+ }),
172
+ createPhoneNumber({
173
+ client_id: "noPhoneMatch",
174
+ phone_number: "+97250000000",
175
+ }),
176
+ ]);
177
+
178
+ const page = await findClientsPage({
179
+ skip: 0,
180
+ limit: 10,
181
+ search: "+9723382",
182
+ });
183
+
184
+ expect(page.clients.map((c) => c.clientId)).toEqual(["phoneOwner"]);
185
+ });
186
+
187
+ it("filters to one exact clientId", async () => {
188
+ await insertClientsCreatedAt("exactClient", "exactClient-2");
189
+
190
+ const page = await findClientsPage({
191
+ skip: 0,
192
+ limit: 10,
193
+ clientId: "exactClient",
194
+ });
195
+
196
+ expect(page.clients.map((c) => c.clientId)).toEqual(["exactClient"]);
197
+ });
198
+
199
+ it("restricts to clientIds and leaves out excludeClientIds", async () => {
200
+ await insertClientsCreatedAt("listA", "listB", "listC");
201
+
202
+ const included = await findClientsPage({
203
+ skip: 0,
204
+ limit: 10,
205
+ clientIds: ["listA", "listC"],
206
+ });
207
+ const excluded = await findClientsPage({
208
+ skip: 0,
209
+ limit: 10,
210
+ excludeClientIds: ["listA"],
211
+ });
212
+
213
+ expect(included.clients.map((c) => c.clientId)).toEqual([
214
+ "listC",
215
+ "listA",
216
+ ]);
217
+ expect(excluded.clients.map((c) => c.clientId)).toEqual([
218
+ "listC",
219
+ "listB",
220
+ ]);
221
+ expect(excluded.total).toBe(2);
222
+ });
223
+ });
224
+ });