@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.
- package/README.md +64 -44
- package/dist/municipal/tickets/index.d.ts +1 -1
- package/dist/municipal/tickets/index.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.statistics.getters.js +19 -6
- package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.statistics.getters.js +39 -10
- package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.statistics.types.d.ts +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -1
- package/dist/talkpilot/contextNotes/contextNotes.getters.d.ts.map +1 -1
- package/dist/talkpilot/contextNotes/contextNotes.getters.js +1 -3
- package/dist/talkpilot/contextNotes/contextNotes.getters.js.map +1 -1
- package/dist/talkpilot/flows/flows.schema.d.ts +3 -0
- package/dist/talkpilot/flows/flows.schema.d.ts.map +1 -1
- package/dist/talkpilot/flows/flows.schema.js +1 -0
- package/dist/talkpilot/flows/flows.schema.js.map +1 -1
- package/dist/talkpilot/flows/flows.types.d.ts +2 -0
- package/dist/talkpilot/flows/flows.types.d.ts.map +1 -1
- package/dist/talkpilot/subscriptions/subscriptions.utils.d.ts +4 -0
- package/dist/talkpilot/subscriptions/subscriptions.utils.d.ts.map +1 -0
- package/dist/talkpilot/subscriptions/subscriptions.utils.js +20 -0
- package/dist/talkpilot/subscriptions/subscriptions.utils.js.map +1 -0
- package/dist/test-utils/factories/talkpilot/contextNotes.d.ts.map +1 -1
- package/dist/test-utils/factories/talkpilot/contextNotes.js +4 -1
- package/dist/test-utils/factories/talkpilot/contextNotes.js.map +1 -1
- package/dist/test-utils/factories/talkpilot/flows.d.ts.map +1 -1
- package/dist/test-utils/factories/talkpilot/flows.js +1 -0
- package/dist/test-utils/factories/talkpilot/flows.js.map +1 -1
- package/dist/utils/date.utils.d.ts.map +1 -1
- package/dist/utils/date.utils.js +7 -2
- package/dist/utils/date.utils.js.map +1 -1
- package/package.json +1 -1
- package/src/municipal/muniIssues/__tests__/muniIssues.setters.spec.ts +3 -1
- package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +9 -4
- package/src/municipal/tickets/index.ts +7 -1
- package/src/municipal/tickets/tickets.statistics.getters.ts +48 -12
- package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +144 -36
- package/src/talkpilot/calls/calls.statistics.getters.ts +122 -44
- package/src/talkpilot/calls/calls.statistics.types.ts +1 -0
- package/src/talkpilot/contextNotes/__tests__/contextNotes.getters.spec.ts +61 -20
- package/src/talkpilot/contextNotes/contextNotes.getters.ts +3 -4
- package/src/talkpilot/flows/flows.schema.ts +1 -0
- package/src/talkpilot/flows/flows.types.ts +2 -0
- package/src/talkpilot/products/__tests__/products.getters.spec.ts +0 -1
- package/src/test-utils/factories/talkpilot/contextNotes.ts +10 -3
- package/src/test-utils/factories/talkpilot/flows.ts +1 -0
- package/src/utils/date.utils.ts +17 -7
- 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(
|
|
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(
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
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({
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
177
|
-
|
|
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({
|
|
191
|
-
|
|
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(
|
|
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
|
-
(
|
|
213
|
-
(
|
|
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(
|
|
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(
|
|
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",
|
|
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",
|
|
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([
|
|
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);
|
|
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(
|
|
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", {
|
|
286
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
313
|
-
|
|
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(
|
|
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(
|
|
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({
|
|
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(
|
|
472
|
+
const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity(
|
|
473
|
+
CITY,
|
|
474
|
+
dateScope,
|
|
475
|
+
);
|
|
370
476
|
const summary = await summaryWithTicketSids();
|
|
371
477
|
|
|
372
|
-
expect([...callSidDraftTicketCounts.keys()].sort()).toEqual(
|
|
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 = (
|
|
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
|
|
106
|
+
time == null
|
|
107
|
+
? undefined
|
|
108
|
+
: Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
|
104
109
|
|
|
105
|
-
const hourMatch = (
|
|
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 = (
|
|
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
|
-
{
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 = (
|
|
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(
|
|
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 (
|
|
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 (
|
|
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 = (
|
|
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)
|
|
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(
|
|
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: {
|
|
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 =
|
|
421
|
-
|
|
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 =
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
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(
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|