@talkpilot/core-db 1.3.11 → 1.3.13

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 (50) hide show
  1. package/README.md +64 -44
  2. package/dist/municipal/tickets/index.d.ts +1 -1
  3. package/dist/municipal/tickets/index.d.ts.map +1 -1
  4. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -1
  5. package/dist/municipal/tickets/tickets.statistics.getters.js +19 -6
  6. package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -1
  7. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -1
  8. package/dist/talkpilot/calls/calls.statistics.getters.js +39 -10
  9. package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -1
  10. package/dist/talkpilot/calls/calls.statistics.types.d.ts +1 -0
  11. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -1
  12. package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts.map +1 -1
  13. package/dist/talkpilot/contextNotes/contextNotes.getters.js +1 -3
  14. package/dist/talkpilot/contextNotes/contextNotes.getters.js.map +1 -1
  15. package/dist/talkpilot/flows/flows.schema.d.ts +3 -0
  16. package/dist/talkpilot/flows/flows.schema.d.ts.map +1 -1
  17. package/dist/talkpilot/flows/flows.schema.js +1 -0
  18. package/dist/talkpilot/flows/flows.schema.js.map +1 -1
  19. package/dist/talkpilot/flows/flows.types.d.ts +2 -0
  20. package/dist/talkpilot/flows/flows.types.d.ts.map +1 -1
  21. package/dist/talkpilot/subscriptions/subscriptions.utils.d.ts +4 -0
  22. package/dist/talkpilot/subscriptions/subscriptions.utils.d.ts.map +1 -0
  23. package/dist/talkpilot/subscriptions/subscriptions.utils.js +20 -0
  24. package/dist/talkpilot/subscriptions/subscriptions.utils.js.map +1 -0
  25. package/dist/test-utils/factories/talkpilot/contextNotes.d.ts.map +1 -1
  26. package/dist/test-utils/factories/talkpilot/contextNotes.js +4 -1
  27. package/dist/test-utils/factories/talkpilot/contextNotes.js.map +1 -1
  28. package/dist/test-utils/factories/talkpilot/flows.d.ts.map +1 -1
  29. package/dist/test-utils/factories/talkpilot/flows.js +1 -0
  30. package/dist/test-utils/factories/talkpilot/flows.js.map +1 -1
  31. package/dist/utils/date.utils.d.ts.map +1 -1
  32. package/dist/utils/date.utils.js +7 -2
  33. package/dist/utils/date.utils.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/municipal/muniIssues/__tests__/muniIssues.setters.spec.ts +3 -1
  36. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +9 -4
  37. package/src/municipal/tickets/index.ts +7 -1
  38. package/src/municipal/tickets/tickets.statistics.getters.ts +48 -12
  39. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +144 -36
  40. package/src/talkpilot/calls/calls.statistics.getters.ts +122 -44
  41. package/src/talkpilot/calls/calls.statistics.types.ts +1 -0
  42. package/src/talkpilot/contextNotes/__tests__/contextNotes.getters.spec.ts +61 -20
  43. package/src/talkpilot/contextNotes/contextNotes.getters.ts +3 -4
  44. package/src/talkpilot/flows/flows.schema.ts +1 -0
  45. package/src/talkpilot/flows/flows.types.ts +2 -0
  46. package/src/talkpilot/products/__tests__/products.getters.spec.ts +0 -1
  47. package/src/test-utils/factories/talkpilot/contextNotes.ts +10 -3
  48. package/src/test-utils/factories/talkpilot/flows.ts +1 -0
  49. package/src/utils/date.utils.ts +17 -7
  50. package/README_OLD.md +0 -160
@@ -52,14 +52,26 @@ const filter = (overrides?: Partial<CallsStatsFilter>): CallsStatsFilter => ({
52
52
  });
53
53
 
54
54
  const ticketDateScope = (statsFilter: CallsStatsFilter) =>
55
- ticketStatsDateScopeFromYmd(statsFilter.startStr, statsFilter.endStr, statsFilter.timezone);
55
+ ticketStatsDateScopeFromYmd(
56
+ statsFilter.startStr,
57
+ statsFilter.endStr,
58
+ statsFilter.timezone,
59
+ );
56
60
 
57
61
  const summaryWithTicketSids = async (overrides?: Partial<CallsStatsFilter>) => {
58
62
  const statsFilter = filter(overrides);
59
63
  const dateScope = ticketDateScope(statsFilter);
60
- const callSidTicketCounts = await findCallSidTicketCountsByCity(CITY, dateScope);
61
- const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity(CITY, dateScope);
62
- return aggregateCallsSummary(filter({ callSidTicketCounts, callSidDraftTicketCounts, ...overrides }));
64
+ const callSidTicketCounts = await findCallSidTicketCountsByCity(
65
+ CITY,
66
+ dateScope,
67
+ );
68
+ const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity(
69
+ CITY,
70
+ dateScope,
71
+ );
72
+ return aggregateCallsSummary(
73
+ filter({ callSidTicketCounts, callSidDraftTicketCounts, ...overrides }),
74
+ );
63
75
  };
64
76
 
65
77
  describe("calls statistics getters", () => {
@@ -117,10 +129,14 @@ describe("calls statistics getters", () => {
117
129
  openTickets: 2,
118
130
  draftTickets: 0,
119
131
  avgDurationSeconds: 90,
132
+ callsWithTickets: 1,
120
133
  });
121
134
 
122
135
  const statsFilter = filter();
123
- const callSidTicketCounts = await findCallSidTicketCountsByCity(CITY, ticketDateScope(statsFilter));
136
+ const callSidTicketCounts = await findCallSidTicketCountsByCity(
137
+ CITY,
138
+ ticketDateScope(statsFilter),
139
+ );
124
140
  const openedOnly = await aggregateCallsSummary(
125
141
  filter({ callSidTicketCounts, ticketStatusFilters: ["opened"] }),
126
142
  );
@@ -137,9 +153,21 @@ describe("calls statistics getters", () => {
137
153
 
138
154
  it("should aggregate subjects only for calls passing the filter", async () => {
139
155
  await getDepartmentsSubjectsCollection().insertMany([
140
- createDepartmentSubject({ cityName: CITY, subject_id: "100", subjectName: "Water" }),
141
- createDepartmentSubject({ cityName: CITY, subject_id: "200", subjectName: "Roads" }),
142
- createDepartmentSubject({ cityName: CITY, subject_id: "300", subjectName: "Lighting" }),
156
+ createDepartmentSubject({
157
+ cityName: CITY,
158
+ subject_id: "100",
159
+ subjectName: "Water",
160
+ }),
161
+ createDepartmentSubject({
162
+ cityName: CITY,
163
+ subject_id: "200",
164
+ subjectName: "Roads",
165
+ }),
166
+ createDepartmentSubject({
167
+ cityName: CITY,
168
+ subject_id: "300",
169
+ subjectName: "Lighting",
170
+ }),
143
171
  ]);
144
172
  await getTicketsCollection().insertMany([
145
173
  createTicket({
@@ -159,7 +187,13 @@ describe("calls statistics getters", () => {
159
187
  ]);
160
188
 
161
189
  const statsFilter = filter();
162
- expect(await findSubjectsByCityAndDateRange(CITY, statsFilter, ticketDateScope(statsFilter))).toEqual([
190
+ expect(
191
+ await findSubjectsByCityAndDateRange(
192
+ CITY,
193
+ statsFilter,
194
+ ticketDateScope(statsFilter),
195
+ ),
196
+ ).toEqual([
163
197
  { subject: "Water", count: 2 },
164
198
  { subject: "Lighting", count: 1 },
165
199
  { subject: "Roads", count: 1 },
@@ -167,14 +201,26 @@ describe("calls statistics getters", () => {
167
201
 
168
202
  const transferredOnly = filter({ callRouting: ["transferred"] });
169
203
  expect(
170
- await findSubjectsByCityAndDateRange(CITY, transferredOnly, ticketDateScope(transferredOnly)),
204
+ await findSubjectsByCityAndDateRange(
205
+ CITY,
206
+ transferredOnly,
207
+ ticketDateScope(transferredOnly),
208
+ ),
171
209
  ).toEqual([{ subject: "Roads", count: 1 }]);
172
210
  });
173
211
 
174
212
  it("should aggregate subjects over a wide date range", async () => {
175
213
  await getDepartmentsSubjectsCollection().insertMany([
176
- createDepartmentSubject({ cityName: CITY, subject_id: "100", subjectName: "Water" }),
177
- createDepartmentSubject({ cityName: CITY, subject_id: "200", subjectName: "Roads" }),
214
+ createDepartmentSubject({
215
+ cityName: CITY,
216
+ subject_id: "100",
217
+ subjectName: "Water",
218
+ }),
219
+ createDepartmentSubject({
220
+ cityName: CITY,
221
+ subject_id: "200",
222
+ subjectName: "Roads",
223
+ }),
178
224
  ]);
179
225
  await incoming("CA-old-roads", "2024-01-01T10:00:00.000Z");
180
226
  await getTicketsCollection().insertOne(
@@ -187,8 +233,17 @@ describe("calls statistics getters", () => {
187
233
  }),
188
234
  );
189
235
 
190
- const statsFilter = filter({ startStr: "2024-01-01", endStr: "2026-12-31" });
191
- expect(await findSubjectsByCityAndDateRange(CITY, statsFilter, ticketDateScope(statsFilter))).toEqual([
236
+ const statsFilter = filter({
237
+ startStr: "2024-01-01",
238
+ endStr: "2026-12-31",
239
+ });
240
+ expect(
241
+ await findSubjectsByCityAndDateRange(
242
+ CITY,
243
+ statsFilter,
244
+ ticketDateScope(statsFilter),
245
+ ),
246
+ ).toEqual([
192
247
  { subject: "Water", count: 2 },
193
248
  { subject: "Roads", count: 1 },
194
249
  ]);
@@ -196,7 +251,10 @@ describe("calls statistics getters", () => {
196
251
 
197
252
  it("should aggregate hourly buckets with averages and optional hour window", async () => {
198
253
  const statsFilter = filter();
199
- const daysInRange = rangeDurationDays(statsFilter.startStr, statsFilter.endStr);
254
+ const daysInRange = rangeDurationDays(
255
+ statsFilter.startStr,
256
+ statsFilter.endStr,
257
+ );
200
258
  const full = await aggregateCallsHourlyByRange(statsFilter);
201
259
 
202
260
  expect(full).toHaveLength(24);
@@ -209,20 +267,30 @@ describe("calls statistics getters", () => {
209
267
  });
210
268
 
211
269
  expect(
212
- (await aggregateCallsHourlyByRange(filter({ startStr: "2026-05-12", endStr: "2026-05-12" }))).find(
213
- (h) => h.hour === 12,
214
- ),
270
+ (
271
+ await aggregateCallsHourlyByRange(
272
+ filter({ startStr: "2026-05-12", endStr: "2026-05-12" }),
273
+ )
274
+ ).find((h) => h.hour === 12),
215
275
  ).toEqual({ hour: 12, totalCalls: 1, avgCalls: 1 });
216
276
 
217
277
  const avgForOneCall = Math.round((1 / daysInRange) * 10) / 10;
218
- expect(await aggregateCallsHourlyByRange(filter({ hourFrom: "10:00", hourTo: "13:00" }))).toEqual([
278
+ expect(
279
+ await aggregateCallsHourlyByRange(
280
+ filter({ hourFrom: "10:00", hourTo: "13:00" }),
281
+ ),
282
+ ).toEqual([
219
283
  { hour: 10, totalCalls: 0, avgCalls: 0 },
220
284
  { hour: 11, totalCalls: 0, avgCalls: 0 },
221
285
  { hour: 12, totalCalls: 1, avgCalls: avgForOneCall },
222
286
  ]);
223
287
 
224
288
  // hourTo is exclusive: ending at "12:00" stops before the 12:00 call, last bucket is 11.
225
- expect(await aggregateCallsHourlyByRange(filter({ hourFrom: "10:00", hourTo: "12:00" }))).toEqual([
289
+ expect(
290
+ await aggregateCallsHourlyByRange(
291
+ filter({ hourFrom: "10:00", hourTo: "12:00" }),
292
+ ),
293
+ ).toEqual([
226
294
  { hour: 10, totalCalls: 0, avgCalls: 0 },
227
295
  { hour: 11, totalCalls: 0, avgCalls: 0 },
228
296
  ]);
@@ -249,9 +317,9 @@ describe("calls statistics getters", () => {
249
317
 
250
318
  it("should respect wrap-around hour window boundaries on both the first and last day", async () => {
251
319
  await incoming("CA-jun8-morning", "2026-06-08T07:00:00.000Z"); // Jun 8 10:00 local — excluded (first-day guard)
252
- await incoming("CA-jun8-night", "2026-06-08T20:30:00.000Z"); // Jun 8 23:30 local — included
320
+ await incoming("CA-jun8-night", "2026-06-08T20:30:00.000Z"); // Jun 8 23:30 local — included
253
321
  await incoming("CA-jun9-morning", "2026-06-09T05:00:00.000Z"); // Jun 9 08:00 local — included
254
- await incoming("CA-jun9-noon", "2026-06-09T10:30:00.000Z"); // Jun 9 13:30 local — excluded (>= hourTo)
322
+ await incoming("CA-jun9-noon", "2026-06-09T10:30:00.000Z"); // Jun 9 13:30 local — excluded (>= hourTo)
255
323
  await incoming("CA-jun9-evening", "2026-06-09T20:30:00.000Z"); // Jun 9 23:30 local — excluded (last-day guard)
256
324
 
257
325
  const twoDay = { startStr: "2026-06-08", endStr: "2026-06-09" } as const;
@@ -265,14 +333,21 @@ describe("calls statistics getters", () => {
265
333
 
266
334
  // Hourly: hours [23, 0, ..., 12] — only the 2 included calls appear.
267
335
  const hourly = await aggregateCallsHourlyByRange(f);
268
- expect(hourly.map((b) => b.hour)).toEqual([23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
336
+ expect(hourly.map((b) => b.hour)).toEqual([
337
+ 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
338
+ ]);
269
339
  expect(hourly.find((b) => b.hour === 23)?.totalCalls).toBe(1); // Jun 8 23:30
270
- expect(hourly.find((b) => b.hour === 8)?.totalCalls).toBe(1); // Jun 9 08:00
340
+ expect(hourly.find((b) => b.hour === 8)?.totalCalls).toBe(1); // Jun 9 08:00
271
341
  expect(hourly.reduce((sum, b) => sum + b.totalCalls, 0)).toBe(2);
272
342
  });
273
343
 
274
344
  it("should fill day trend buckets with zeros", async () => {
275
- expect(await aggregateCallsTrend(filter({ startStr: "2026-05-10", endStr: "2026-05-14" }), "day")).toEqual([
345
+ expect(
346
+ await aggregateCallsTrend(
347
+ filter({ startStr: "2026-05-10", endStr: "2026-05-14" }),
348
+ "day",
349
+ ),
350
+ ).toEqual([
276
351
  { bucket: "2026-05-10", totalCalls: 1, openTickets: 0 },
277
352
  { bucket: "2026-05-11", totalCalls: 1, openTickets: 0 },
278
353
  { bucket: "2026-05-12", totalCalls: 1, openTickets: 0 },
@@ -282,8 +357,15 @@ describe("calls statistics getters", () => {
282
357
  });
283
358
 
284
359
  it("should bucket calls by calendar day in filter timezone", async () => {
285
- await incoming("CA-jerusalem-midnight", "2026-05-09T21:00:00.000Z", { callLength: 45 });
286
- expect(await aggregateCallsTrend(filter({ startStr: "2026-05-10", endStr: "2026-05-12" }), "day")).toEqual([
360
+ await incoming("CA-jerusalem-midnight", "2026-05-09T21:00:00.000Z", {
361
+ callLength: 45,
362
+ });
363
+ expect(
364
+ await aggregateCallsTrend(
365
+ filter({ startStr: "2026-05-10", endStr: "2026-05-12" }),
366
+ "day",
367
+ ),
368
+ ).toEqual([
287
369
  { bucket: "2026-05-10", totalCalls: 2, openTickets: 0 },
288
370
  { bucket: "2026-05-11", totalCalls: 1, openTickets: 0 },
289
371
  { bucket: "2026-05-12", totalCalls: 1, openTickets: 0 },
@@ -292,7 +374,9 @@ describe("calls statistics getters", () => {
292
374
 
293
375
  it("should pick month, week, or day bucket from calendar-month span", () => {
294
376
  expect(rangeDurationMonths("2025-06-01", "2026-05-31")).toBe(12);
295
- expect(resolveTimeBucketFromRange("2025-06-01", "2026-05-31")).toBe("month");
377
+ expect(resolveTimeBucketFromRange("2025-06-01", "2026-05-31")).toBe(
378
+ "month",
379
+ );
296
380
  expect(resolveTimeBucketFromRange("2026-01-01", "2026-06-30")).toBe("week");
297
381
  expect(resolveTimeBucketFromRange("2026-04-01", "2026-06-30")).toBe("day");
298
382
  });
@@ -300,7 +384,10 @@ describe("calls statistics getters", () => {
300
384
  it("should fill month trend buckets with zeros", async () => {
301
385
  await incoming("CA-feb", "2026-02-15T10:00:00.000Z");
302
386
  await incoming("CA-apr", "2026-04-15T10:00:00.000Z");
303
- const month = await aggregateCallsTrend(filter({ startStr: "2026-01-01", endStr: "2026-06-30" }), "month");
387
+ const month = await aggregateCallsTrend(
388
+ filter({ startStr: "2026-01-01", endStr: "2026-06-30" }),
389
+ "month",
390
+ );
304
391
  expect(month.map((row) => row.bucket)).toEqual([
305
392
  "2026-01-01",
306
393
  "2026-02-01",
@@ -309,14 +396,23 @@ describe("calls statistics getters", () => {
309
396
  "2026-05-01",
310
397
  "2026-06-01",
311
398
  ]);
312
- expect(month.find((row) => row.bucket === "2026-02-01")).toMatchObject({ totalCalls: 1, openTickets: 0 });
313
- expect(month.find((row) => row.bucket === "2026-05-01")).toMatchObject({ totalCalls: 3, openTickets: 0 });
399
+ expect(month.find((row) => row.bucket === "2026-02-01")).toMatchObject({
400
+ totalCalls: 1,
401
+ openTickets: 0,
402
+ });
403
+ expect(month.find((row) => row.bucket === "2026-05-01")).toMatchObject({
404
+ totalCalls: 3,
405
+ openTickets: 0,
406
+ });
314
407
  });
315
408
 
316
409
  it("should fill week trend buckets with zeros", async () => {
317
410
  await incoming("CA-week-2", "2026-05-18T10:00:00.000Z");
318
411
  expect(
319
- await aggregateCallsTrend(filter({ startStr: "2026-05-04", endStr: "2026-05-24" }), "week"),
412
+ await aggregateCallsTrend(
413
+ filter({ startStr: "2026-05-04", endStr: "2026-05-24" }),
414
+ "week",
415
+ ),
320
416
  ).toEqual([
321
417
  { bucket: "2026-05-04/2026-05-09", totalCalls: 0, openTickets: 0 },
322
418
  { bucket: "2026-05-10/2026-05-16", totalCalls: 3, openTickets: 0 },
@@ -329,11 +425,18 @@ describe("calls statistics getters", () => {
329
425
  const startStr = "2025-07-02";
330
426
  const endStr = "2026-06-02";
331
427
  const startedAt = Date.now();
332
- const trend = await aggregateCallsTrend(filter({ startStr, endStr }), "day");
428
+ const trend = await aggregateCallsTrend(
429
+ filter({ startStr, endStr }),
430
+ "day",
431
+ );
333
432
  const elapsedMs = Date.now() - startedAt;
334
433
 
335
434
  expect(trend).toHaveLength(rangeDurationDays(startStr, endStr));
336
- expect(trend[0]).toEqual({ bucket: startStr, totalCalls: 0, openTickets: 0 });
435
+ expect(trend[0]).toEqual({
436
+ bucket: startStr,
437
+ totalCalls: 0,
438
+ openTickets: 0,
439
+ });
337
440
  expect(trend[trend.length - 1].bucket).toBe(endStr);
338
441
  expect(elapsedMs).toBeLessThan(5000);
339
442
  });
@@ -366,10 +469,15 @@ describe("calls statistics getters", () => {
366
469
 
367
470
  const statsFilter = filter();
368
471
  const dateScope = ticketDateScope(statsFilter);
369
- const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity(CITY, dateScope);
472
+ const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity(
473
+ CITY,
474
+ dateScope,
475
+ );
370
476
  const summary = await summaryWithTicketSids();
371
477
 
372
- expect([...callSidDraftTicketCounts.keys()].sort()).toEqual(["CA-hijacked", "CA-whitespace-draft"].sort());
478
+ expect([...callSidDraftTicketCounts.keys()].sort()).toEqual(
479
+ ["CA-hijacked", "CA-whitespace-draft"].sort(),
480
+ );
373
481
  expect(summary).toMatchObject({ openTickets: 4, draftTickets: 1 });
374
482
  });
375
483
  });
@@ -41,6 +41,7 @@ const emptySummary = (): CallsSummaryAggregation => ({
41
41
  avgDurationSeconds: 0,
42
42
  openTickets: 0,
43
43
  draftTickets: 0,
44
+ callsWithTickets: 0,
44
45
  });
45
46
 
46
47
  const emptyRouting = (): CallsRoutingAggregation => ({
@@ -62,7 +63,9 @@ const countIf = (condition: Record<string, unknown>) => ({
62
63
  $sum: { $cond: [condition, 1, 0] },
63
64
  });
64
65
 
65
- const needsTicketStatusFilter = (filters?: CallsStatsFilter["ticketStatusFilters"]) => {
66
+ const needsTicketStatusFilter = (
67
+ filters?: CallsStatsFilter["ticketStatusFilters"],
68
+ ) => {
66
69
  if (!filters?.length) return false;
67
70
  const hasOpened = filters.includes("opened");
68
71
  const hasNotOpened = filters.includes("not_opened");
@@ -100,9 +103,13 @@ const ticketStatusMatch = (filter: CallsStatsFilter) => {
100
103
 
101
104
  // "HH:mm" → minute-of-day (0-1439), for the per-call time-of-day filter.
102
105
  const parseMinuteOfDay = (time?: string): number | undefined =>
103
- time == null ? undefined : Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
106
+ time == null
107
+ ? undefined
108
+ : Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
104
109
 
105
- const hourMatch = (filter: CallsStatsFilter): Record<string, unknown> | null => {
110
+ const hourMatch = (
111
+ filter: CallsStatsFilter,
112
+ ): Record<string, unknown> | null => {
106
113
  const from = parseMinuteOfDay(filter.hourFrom);
107
114
  const to = parseMinuteOfDay(filter.hourTo);
108
115
  if (from == null && to == null) return null;
@@ -133,7 +140,9 @@ const createdAtMatchForFilter = (
133
140
  return { createdAt: { $gte: bounds.from!, $lte: bounds.to! } };
134
141
  };
135
142
 
136
- const buildCallsMatchStages = (filter: CallsStatsFilter): Record<string, unknown>[] => {
143
+ const buildCallsMatchStages = (
144
+ filter: CallsStatsFilter,
145
+ ): Record<string, unknown>[] => {
137
146
  const hourRange = hourMatch(filter);
138
147
  const routing = routingMatch(filter.callRouting);
139
148
  const ticketMatch = ticketStatusMatch(filter);
@@ -158,7 +167,12 @@ const buildCallsMatchStages = (filter: CallsStatsFilter): Record<string, unknown
158
167
  hour: { $hour: { date: "$createdAt", timezone: filter.timezone } },
159
168
  minuteOfDay: {
160
169
  $add: [
161
- { $multiply: [{ $hour: { date: "$createdAt", timezone: filter.timezone } }, 60] },
170
+ {
171
+ $multiply: [
172
+ { $hour: { date: "$createdAt", timezone: filter.timezone } },
173
+ 60,
174
+ ],
175
+ },
162
176
  { $minute: { date: "$createdAt", timezone: filter.timezone } },
163
177
  ],
164
178
  },
@@ -183,14 +197,16 @@ const trendBucketExpr = (bucket: ResolvedTimeBucket, timezone: string) =>
183
197
  bucket === "day"
184
198
  ? { bucket: "$dateLocal" }
185
199
  : {
186
- bucket: {
187
- $dateToString: {
188
- format: "%Y-%m-%d",
189
- date: { $dateTrunc: { date: "$createdAt", unit: bucket, timezone } },
190
- timezone,
200
+ bucket: {
201
+ $dateToString: {
202
+ format: "%Y-%m-%d",
203
+ date: {
204
+ $dateTrunc: { date: "$createdAt", unit: bucket, timezone },
205
+ },
206
+ timezone,
207
+ },
191
208
  },
192
- },
193
- };
209
+ };
194
210
 
195
211
  const mapTrendRow = (row: {
196
212
  _id: unknown;
@@ -212,7 +228,10 @@ export const rangeDurationDays = (startStr: string, endStr: string): number => {
212
228
  * Number of distinct calendar months the [startStr, endStr] range spans, inclusive.
213
229
  * Counts month boundaries rather than 30-day periods: e.g. Jan 31 -> Feb 1 returns 2.
214
230
  */
215
- export const rangeDurationMonths = (startStr: string, endStr: string): number => {
231
+ export const rangeDurationMonths = (
232
+ startStr: string,
233
+ endStr: string,
234
+ ): number => {
216
235
  const [sy, sm] = parseYmd(startStr);
217
236
  const [ey, em] = parseYmd(endStr);
218
237
  return (ey - sy) * 12 + (em - sm) + 1;
@@ -235,7 +254,9 @@ export const resolveTimeBucketFromRange = (
235
254
 
236
255
  const weekBucketKeyForDate = (dateStr: string, timezone: string): string => {
237
256
  const anchor = dateAtNoonUtc(dateStr);
238
- const weekStart = new Date(anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS);
257
+ const weekStart = new Date(
258
+ anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS,
259
+ );
239
260
  return formatYmdInTz(weekStart, timezone);
240
261
  };
241
262
 
@@ -270,7 +291,11 @@ const fillDailyTrendBuckets = (
270
291
  const byBucket = indexTrendRowsByBucket(rows);
271
292
  const filled: CallsTrendBucket[] = [];
272
293
 
273
- for (let cursor = startStr; cursor <= endStr; cursor = ymdToStr(...incrementYmd(...parseYmd(cursor)))) {
294
+ for (
295
+ let cursor = startStr;
296
+ cursor <= endStr;
297
+ cursor = ymdToStr(...incrementYmd(...parseYmd(cursor)))
298
+ ) {
274
299
  filled.push(byBucket.get(cursor) ?? emptyTrendBucket(cursor));
275
300
  }
276
301
 
@@ -313,7 +338,11 @@ const fillWeeklyTrendBuckets = (
313
338
  const filled: CallsTrendBucket[] = [];
314
339
  const seen = new Set<string>();
315
340
 
316
- for (let i = 0, days = rangeDurationDays(startStr, endStr); i < days; i += 1) {
341
+ for (
342
+ let i = 0, days = rangeDurationDays(startStr, endStr);
343
+ i < days;
344
+ i += 1
345
+ ) {
317
346
  const bucket = formatWeekBucketRange(
318
347
  weekBucketKeyForDate(addDaysToDateStr(startStr, i), timezone),
319
348
  startStr,
@@ -347,10 +376,13 @@ const fillTrendBuckets = (
347
376
  }
348
377
  };
349
378
 
350
- const resolveHourRange = (filter: CallsStatsFilter): { hourFrom: number; hourTo: number } => {
379
+ const resolveHourRange = (
380
+ filter: CallsStatsFilter,
381
+ ): { hourFrom: number; hourTo: number } => {
351
382
  const from = parseMinuteOfDay(filter.hourFrom);
352
383
  const to = parseMinuteOfDay(filter.hourTo);
353
- if (from == null && to == null) return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 };
384
+ if (from == null && to == null)
385
+ return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 };
354
386
  return {
355
387
  hourFrom: from == null ? 0 : Math.floor(from / 60),
356
388
  // hourTo is exclusive: last populated bucket is the hour holding minute (to - 1).
@@ -392,12 +424,18 @@ const toHourlyDistribution = (
392
424
  export const aggregateCallsSummary = async (
393
425
  filter: CallsStatsFilter,
394
426
  ): Promise<CallsSummaryAggregation> => {
395
- const hasTickets = Boolean(filter.callSidTicketCounts?.size || filter.callSidDraftTicketCounts?.size);
427
+ const hasTickets = Boolean(
428
+ filter.callSidTicketCounts?.size || filter.callSidDraftTicketCounts?.size,
429
+ );
396
430
  const hasStatusFilter = needsTicketStatusFilter(filter.ticketStatusFilters);
397
431
 
398
432
  if (!hasTickets && !hasStatusFilter) {
399
433
  const result = await aggregateCalls(filter, {
400
- $group: { _id: null, totalCalls: { $sum: 1 }, avgDurationSeconds: { $avg: "$callLength" } },
434
+ $group: {
435
+ _id: null,
436
+ totalCalls: { $sum: 1 },
437
+ avgDurationSeconds: { $avg: "$callLength" },
438
+ },
401
439
  }).next();
402
440
  if (!result) return emptySummary();
403
441
  return {
@@ -405,6 +443,7 @@ export const aggregateCallsSummary = async (
405
443
  avgDurationSeconds: roundOneDecimal(result.avgDurationSeconds ?? 0),
406
444
  openTickets: 0,
407
445
  draftTickets: 0,
446
+ callsWithTickets: 0,
408
447
  };
409
448
  }
410
449
 
@@ -417,13 +456,16 @@ export const aggregateCallsSummary = async (
417
456
 
418
457
  const ticketMap = filter.callSidTicketCounts ?? new Map<string, number>();
419
458
  const draftMap = filter.callSidDraftTicketCounts ?? new Map<string, number>();
420
- const openedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("opened");
421
- const notOpenedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened");
459
+ const openedOnly =
460
+ hasStatusFilter && filter.ticketStatusFilters!.includes("opened");
461
+ const notOpenedOnly =
462
+ hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened");
422
463
 
423
464
  let totalCalls = 0;
424
465
  let totalDuration = 0;
425
466
  let openTickets = 0;
426
467
  let draftTickets = 0;
468
+ let callsWithTickets = 0;
427
469
 
428
470
  for (const row of rows) {
429
471
  const sid = row.callSid as string;
@@ -433,6 +475,7 @@ export const aggregateCallsSummary = async (
433
475
  totalCalls += 1;
434
476
  totalDuration += (row.callLength as number | undefined) ?? 0;
435
477
  openTickets += ticketMap.get(sid) ?? 0;
478
+ if (ticketMap.has(sid)) callsWithTickets += 1;
436
479
  draftTickets += draftMap.get(sid) ?? 0;
437
480
  }
438
481
 
@@ -443,6 +486,7 @@ export const aggregateCallsSummary = async (
443
486
  avgDurationSeconds: roundOneDecimal(totalDuration / totalCalls),
444
487
  openTickets,
445
488
  draftTickets,
489
+ callsWithTickets,
446
490
  };
447
491
  };
448
492
 
@@ -461,10 +505,17 @@ export const aggregateCallsTrend = async (
461
505
  { $project: { _id: 0, bucket: 1, callSid: 1 } },
462
506
  ).toArray();
463
507
 
464
- const openedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("opened");
465
- const notOpenedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("not_opened");
466
-
467
- const byBucket = new Map<string, { totalCalls: number; openTickets: number }>();
508
+ const openedOnly =
509
+ needsTicketStatusFilter(filter.ticketStatusFilters) &&
510
+ filter.ticketStatusFilters!.includes("opened");
511
+ const notOpenedOnly =
512
+ needsTicketStatusFilter(filter.ticketStatusFilters) &&
513
+ filter.ticketStatusFilters!.includes("not_opened");
514
+
515
+ const byBucket = new Map<
516
+ string,
517
+ { totalCalls: number; openTickets: number }
518
+ >();
468
519
  for (const row of rows) {
469
520
  const sid = row.callSid as string;
470
521
  const hasTicket = ticketMap.has(sid);
@@ -472,7 +523,11 @@ export const aggregateCallsTrend = async (
472
523
  if (notOpenedOnly && hasTicket) continue;
473
524
  const label =
474
525
  bucket === "week"
475
- ? formatWeekBucketRange(String(row.bucket), filter.startStr, filter.endStr)
526
+ ? formatWeekBucketRange(
527
+ String(row.bucket),
528
+ filter.startStr,
529
+ filter.endStr,
530
+ )
476
531
  : String(row.bucket);
477
532
  const cur = byBucket.get(label) ?? { totalCalls: 0, openTickets: 0 };
478
533
  cur.totalCalls += 1;
@@ -480,10 +535,18 @@ export const aggregateCallsTrend = async (
480
535
  byBucket.set(label, cur);
481
536
  }
482
537
 
483
- const mappedRows = [...byBucket.entries()]
484
- .map(([label, counts]) => ({ bucket: label, ...counts }));
485
-
486
- return fillTrendBuckets(mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone);
538
+ const mappedRows = [...byBucket.entries()].map(([label, counts]) => ({
539
+ bucket: label,
540
+ ...counts,
541
+ }));
542
+
543
+ return fillTrendBuckets(
544
+ mappedRows,
545
+ bucket,
546
+ filter.startStr,
547
+ filter.endStr,
548
+ filter.timezone,
549
+ );
487
550
  }
488
551
 
489
552
  const rows = await aggregateCalls(
@@ -496,15 +559,29 @@ export const aggregateCallsTrend = async (
496
559
  const mappedRows =
497
560
  bucket === "week"
498
561
  ? rows.map((row) => ({
499
- bucket: formatWeekBucketRange(String(row._id), filter.startStr, filter.endStr),
500
- totalCalls: row.totalCalls as number,
501
- openTickets: 0,
502
- }))
562
+ bucket: formatWeekBucketRange(
563
+ String(row._id),
564
+ filter.startStr,
565
+ filter.endStr,
566
+ ),
567
+ totalCalls: row.totalCalls as number,
568
+ openTickets: 0,
569
+ }))
503
570
  : rows.map((row) =>
504
- mapTrendRow({ _id: row._id, totalCalls: row.totalCalls as number, openTickets: 0 }),
505
- );
506
-
507
- return fillTrendBuckets(mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone);
571
+ mapTrendRow({
572
+ _id: row._id,
573
+ totalCalls: row.totalCalls as number,
574
+ openTickets: 0,
575
+ }),
576
+ );
577
+
578
+ return fillTrendBuckets(
579
+ mappedRows,
580
+ bucket,
581
+ filter.startStr,
582
+ filter.endStr,
583
+ filter.timezone,
584
+ );
508
585
  };
509
586
 
510
587
  export const aggregateCallsHourlyByRange = async (
@@ -554,11 +631,12 @@ export const aggregateCallsRouting = async (
554
631
  };
555
632
  };
556
633
 
557
- export const findFilteredCallSids = async (filter: CallsStatsFilter): Promise<string[]> => {
558
- const rows = await aggregateCalls(
559
- filter,
560
- { $group: { _id: "$callSid" } },
561
- ).toArray();
634
+ export const findFilteredCallSids = async (
635
+ filter: CallsStatsFilter,
636
+ ): Promise<string[]> => {
637
+ const rows = await aggregateCalls(filter, {
638
+ $group: { _id: "$callSid" },
639
+ }).toArray();
562
640
  return rows.map((r) => String(r._id)).filter(Boolean);
563
641
  };
564
642
 
@@ -25,6 +25,7 @@ export type CallsSummaryAggregation = {
25
25
  avgDurationSeconds: number;
26
26
  openTickets: number;
27
27
  draftTickets: number;
28
+ callsWithTickets?: number;
28
29
  };
29
30
 
30
31
  export type CallsTrendBucket = {