@talkpilot/core-db 1.3.47 → 1.3.49

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 (240) hide show
  1. package/.cursor/rules/development.mdc +65 -65
  2. package/AGENTS.md +49 -49
  3. package/DEVELOPMENT.md +141 -141
  4. package/README.md +778 -747
  5. package/dist/configuration/models/models.constants.d.ts +1 -1
  6. package/dist/configuration/models/models.constants.d.ts.map +1 -1
  7. package/dist/configuration/models/models.constants.js +1 -1
  8. package/dist/configuration/models/models.constants.js.map +1 -1
  9. package/dist/talkpilot/calls/calls.types.d.ts +14 -0
  10. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  11. package/dist/talkpilot/clients/clients.quota.constants.d.ts +2 -0
  12. package/dist/talkpilot/clients/clients.quota.constants.d.ts.map +1 -0
  13. package/dist/talkpilot/clients/clients.quota.constants.js +5 -0
  14. package/dist/talkpilot/clients/clients.quota.constants.js.map +1 -0
  15. package/dist/talkpilot/clients/clients.quota.utils.d.ts +8 -0
  16. package/dist/talkpilot/clients/clients.quota.utils.d.ts.map +1 -0
  17. package/dist/talkpilot/clients/clients.quota.utils.js +45 -0
  18. package/dist/talkpilot/clients/clients.quota.utils.js.map +1 -0
  19. package/dist/talkpilot/flows/flows.getter.d.ts +4 -1
  20. package/dist/talkpilot/flows/flows.getter.d.ts.map +1 -1
  21. package/dist/talkpilot/flows/flows.getter.js +12 -1
  22. package/dist/talkpilot/flows/flows.getter.js.map +1 -1
  23. package/dist/talkpilot/flows/flows.schema.d.ts +31 -0
  24. package/dist/talkpilot/flows/flows.schema.d.ts.map +1 -1
  25. package/dist/talkpilot/flows/flows.schema.js +25 -0
  26. package/dist/talkpilot/flows/flows.schema.js.map +1 -1
  27. package/dist/talkpilot/flows/flows.types.d.ts +14 -0
  28. package/dist/talkpilot/flows/flows.types.d.ts.map +1 -1
  29. package/dist/talkpilot/flows/index.d.ts +1 -1
  30. package/dist/talkpilot/flows/index.d.ts.map +1 -1
  31. package/dist/talkpilot/flows/index.js +3 -1
  32. package/dist/talkpilot/flows/index.js.map +1 -1
  33. package/dist/websitalk/scanResources/scanResources.getters.d.ts.map +1 -1
  34. package/dist/websitalk/scanResources/scanResources.getters.js +3 -2
  35. package/dist/websitalk/scanResources/scanResources.getters.js.map +1 -1
  36. package/dist/websitalk/scanResources/scanResources.utils.d.ts +1 -0
  37. package/dist/websitalk/scanResources/scanResources.utils.d.ts.map +1 -1
  38. package/dist/websitalk/scanResources/scanResources.utils.js +3 -1
  39. package/dist/websitalk/scanResources/scanResources.utils.js.map +1 -1
  40. package/jest.config.js +20 -20
  41. package/package.json +46 -46
  42. package/src/__tests__/setup.ts +20 -20
  43. package/src/bulkWrite/__tests__/bulkWrite.spec.ts +65 -65
  44. package/src/bulkWrite/bulkWrite.ops.ts +45 -45
  45. package/src/bulkWrite/bulkWrite.types.ts +4 -4
  46. package/src/bulkWrite/index.ts +2 -2
  47. package/src/config.ts +9 -9
  48. package/src/configuration/index.ts +17 -17
  49. package/src/configuration/internalModels/index.ts +3 -3
  50. package/src/configuration/internalModels/internalModels.constants.ts +1 -1
  51. package/src/configuration/internalModels/internalModels.getters.ts +33 -33
  52. package/src/configuration/internalModels/internalModels.types.ts +12 -12
  53. package/src/configuration/models/index.ts +25 -25
  54. package/src/configuration/models/models.constants.ts +13 -13
  55. package/src/configuration/models/models.getters.ts +53 -53
  56. package/src/configuration/models/models.types.ts +79 -79
  57. package/src/configuration/mongodb-client.ts +62 -62
  58. package/src/configuration/prompts/index.ts +2 -2
  59. package/src/configuration/prompts/prompts.getters.ts +49 -49
  60. package/src/configuration/prompts/prompts.types.ts +11 -11
  61. package/src/connection.ts +81 -81
  62. package/src/index.ts +46 -46
  63. package/src/municipal/__tests__/validation.spec.ts +62 -62
  64. package/src/municipal/cities/cities.getters.ts +50 -50
  65. package/src/municipal/cities/cities.types.ts +11 -11
  66. package/src/municipal/cities/index.ts +2 -2
  67. package/src/municipal/departmentsSubjects/__tests__/departmentsSubjects.spec.ts +272 -272
  68. package/src/municipal/departmentsSubjects/departmentsSubjects.getters.ts +317 -317
  69. package/src/municipal/departmentsSubjects/departmentsSubjects.types.ts +73 -73
  70. package/src/municipal/departmentsSubjects/index.ts +9 -9
  71. package/src/municipal/index.ts +22 -22
  72. package/src/municipal/mongodb-client.ts +61 -61
  73. package/src/municipal/muniIssues/__tests__/muniIssues.setters.spec.ts +94 -94
  74. package/src/municipal/muniIssues/__tests__/muniIssues.setup.spec.ts +92 -92
  75. package/src/municipal/muniIssues/index.ts +17 -17
  76. package/src/municipal/muniIssues/muniIssues.constants.ts +18 -18
  77. package/src/municipal/muniIssues/muniIssues.getters.ts +24 -24
  78. package/src/municipal/muniIssues/muniIssues.schema.ts +66 -66
  79. package/src/municipal/muniIssues/muniIssues.setters.ts +24 -24
  80. package/src/municipal/muniIssues/muniIssues.setup.ts +65 -65
  81. package/src/municipal/muniIssues/muniIssues.types.ts +37 -37
  82. package/src/municipal/streets/__tests__/streets.spec.ts +253 -253
  83. package/src/municipal/streets/index.ts +2 -2
  84. package/src/municipal/streets/streets.getters.ts +140 -140
  85. package/src/municipal/streets/streets.types.ts +19 -19
  86. package/src/municipal/systemInstructions/__tests__/getters.spec.ts +113 -113
  87. package/src/municipal/systemInstructions/__tests__/setters.spec.ts +274 -274
  88. package/src/municipal/systemInstructions/index.ts +7 -7
  89. package/src/municipal/systemInstructions/instructions.getters.ts +57 -57
  90. package/src/municipal/systemInstructions/instructions.setters.ts +119 -119
  91. package/src/municipal/systemInstructions/instructions.types.ts +30 -30
  92. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +30 -30
  93. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +99 -99
  94. package/src/municipal/tickets/index.ts +9 -9
  95. package/src/municipal/tickets/tickets.constants.ts +8 -8
  96. package/src/municipal/tickets/tickets.getters.ts +121 -121
  97. package/src/municipal/tickets/tickets.statistics.aggregation.ts +110 -110
  98. package/src/municipal/tickets/tickets.statistics.getters.ts +145 -145
  99. package/src/municipal/tickets/tickets.types.ts +54 -54
  100. package/src/municipal/utils/types.ts +11 -11
  101. package/src/talkpilot/__tests__/db.spec.ts +38 -38
  102. package/src/talkpilot/__tests__/mongodb-client.spec.ts +18 -18
  103. package/src/talkpilot/__tests__/validation.spec.ts +68 -68
  104. package/src/talkpilot/agents/__tests__/agents.getters.spec.ts +29 -29
  105. package/src/talkpilot/agents/agents.getters.ts +34 -34
  106. package/src/talkpilot/agents/agents.types.ts +14 -14
  107. package/src/talkpilot/agents/index.ts +2 -2
  108. package/src/talkpilot/backgroundToolResults/__tests__/backgroundToolResults.getters.spec.ts +147 -147
  109. package/src/talkpilot/backgroundToolResults/backgroundToolResults.getters.ts +65 -65
  110. package/src/talkpilot/backgroundToolResults/backgroundToolResults.types.ts +23 -23
  111. package/src/talkpilot/backgroundToolResults/index.ts +2 -2
  112. package/src/talkpilot/calls/__tests__/callStats.utils.spec.ts +128 -128
  113. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +229 -229
  114. package/src/talkpilot/calls/__tests__/calls.spec.ts +471 -471
  115. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +483 -483
  116. package/src/talkpilot/calls/calls.constants.ts +49 -49
  117. package/src/talkpilot/calls/calls.getters.ts +277 -277
  118. package/src/talkpilot/calls/calls.statistics.getters.ts +668 -668
  119. package/src/talkpilot/calls/calls.statistics.types.ts +48 -48
  120. package/src/talkpilot/calls/calls.types.ts +149 -134
  121. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +295 -295
  122. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +57 -57
  123. package/src/talkpilot/calls/index.ts +6 -6
  124. package/src/talkpilot/clientAudioBuffers/__tests__/clientAudioBuffer.getters.spec.ts +160 -160
  125. package/src/talkpilot/clientAudioBuffers/clientAudioBuffer.getters.ts +117 -117
  126. package/src/talkpilot/clientAudioBuffers/clientsAudioBuffers.types.ts +25 -25
  127. package/src/talkpilot/clientAudioBuffers/index.ts +2 -2
  128. package/src/talkpilot/clients/__tests__/clients.quota.spec.ts +123 -123
  129. package/src/talkpilot/clients/clients.getters.ts +16 -16
  130. package/src/talkpilot/clients/clients.quota.getters.ts +119 -119
  131. package/src/talkpilot/clients/clients.quota.types.ts +27 -27
  132. package/src/talkpilot/clients/clients.types.ts +18 -18
  133. package/src/talkpilot/clients/index.ts +4 -4
  134. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +423 -423
  135. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +212 -212
  136. package/src/talkpilot/clientsConfig/clientsConfig.constants.ts +2 -2
  137. package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +247 -247
  138. package/src/talkpilot/clientsConfig/clientsConfig.types.ts +136 -136
  139. package/src/talkpilot/clientsConfig/index.ts +3 -3
  140. package/src/talkpilot/contextNotes/__tests__/contextNotes.getters.spec.ts +217 -217
  141. package/src/talkpilot/contextNotes/contextNotes.getters.ts +85 -85
  142. package/src/talkpilot/contextNotes/contextNotes.types.ts +20 -20
  143. package/src/talkpilot/contextNotes/index.ts +2 -2
  144. package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +124 -99
  145. package/src/talkpilot/flows/flows.getter.ts +34 -14
  146. package/src/talkpilot/flows/flows.schema.ts +180 -155
  147. package/src/talkpilot/flows/flows.types.ts +211 -189
  148. package/src/talkpilot/flows/index.ts +7 -2
  149. package/src/talkpilot/groups/__tests__/groups.spec.ts +90 -90
  150. package/src/talkpilot/groups/__tests__/phone.utils.spec.ts +32 -32
  151. package/src/talkpilot/groups/groups.getters.ts +30 -30
  152. package/src/talkpilot/groups/groups.types.ts +29 -29
  153. package/src/talkpilot/groups/index.ts +3 -3
  154. package/src/talkpilot/groups/phone.utils.ts +46 -46
  155. package/src/talkpilot/index.ts +31 -31
  156. package/src/talkpilot/leads/index.ts +2 -2
  157. package/src/talkpilot/leads/leads.getter.ts +6 -6
  158. package/src/talkpilot/leads/leads.schema.ts +33 -33
  159. package/src/talkpilot/leads/leads.types.ts +20 -20
  160. package/src/talkpilot/mongodb-client.ts +78 -78
  161. package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +282 -282
  162. package/src/talkpilot/phone_numbers/index.ts +2 -2
  163. package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +176 -176
  164. package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +17 -17
  165. package/src/talkpilot/phone_numbers/phone_numbers.types.ts +30 -30
  166. package/src/talkpilot/plans/__tests__/plans.spec.ts +70 -70
  167. package/src/talkpilot/plans/index.ts +2 -2
  168. package/src/talkpilot/plans/plans.getters.ts +132 -132
  169. package/src/talkpilot/plans/plans.types.ts +89 -89
  170. package/src/talkpilot/products/__tests__/products.getters.spec.ts +44 -44
  171. package/src/talkpilot/products/index.ts +2 -2
  172. package/src/talkpilot/products/products.getters.ts +12 -12
  173. package/src/talkpilot/products/products.types.ts +9 -9
  174. package/src/talkpilot/results/index.ts +7 -7
  175. package/src/talkpilot/results/results.getter.ts +39 -39
  176. package/src/talkpilot/results/results.schema.ts +25 -25
  177. package/src/talkpilot/results/results.types.ts +34 -34
  178. package/src/talkpilot/retry_analyze/__tests__/retryAnalyze.getters.spec.ts +156 -156
  179. package/src/talkpilot/retry_analyze/index.ts +2 -2
  180. package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +84 -84
  181. package/src/talkpilot/retry_analyze/retryAnalyze.types.ts +13 -13
  182. package/src/talkpilot/sessions/__tests__/sessions.spec.ts +147 -147
  183. package/src/talkpilot/sessions/index.ts +2 -2
  184. package/src/talkpilot/sessions/sessions.getter.ts +92 -92
  185. package/src/talkpilot/sessions/sessions.schema.ts +34 -34
  186. package/src/talkpilot/sessions/sessions.types.ts +30 -30
  187. package/src/talkpilot/subscriptions/__tests__/subscriptions.getters.utils.spec.ts +45 -45
  188. package/src/talkpilot/subscriptions/index.ts +3 -3
  189. package/src/talkpilot/subscriptions/subscriptions.getters.ts +146 -146
  190. package/src/talkpilot/subscriptions/subscriptions.getters.utils.ts +33 -33
  191. package/src/talkpilot/subscriptions/subscriptions.types.ts +66 -66
  192. package/src/talkpilot/utils/__tests__/query.utils.spec.ts +49 -49
  193. package/src/talkpilot/utils/query.utils.ts +21 -21
  194. package/src/test-utils/db-utils.ts +33 -33
  195. package/src/test-utils/factories/index.ts +18 -18
  196. package/src/test-utils/factories/municipal/cities.ts +16 -16
  197. package/src/test-utils/factories/municipal/departmentsSubjects.ts +37 -37
  198. package/src/test-utils/factories/municipal/muniIssues.ts +32 -32
  199. package/src/test-utils/factories/municipal/streets.ts +22 -22
  200. package/src/test-utils/factories/municipal/tickets.ts +39 -39
  201. package/src/test-utils/factories/talkpilot/agents.ts +19 -19
  202. package/src/test-utils/factories/talkpilot/calls.ts +37 -37
  203. package/src/test-utils/factories/talkpilot/clientAudioBuffers.ts +20 -20
  204. package/src/test-utils/factories/talkpilot/clientsConfig.ts +18 -18
  205. package/src/test-utils/factories/talkpilot/contextNotes.ts +40 -40
  206. package/src/test-utils/factories/talkpilot/flows.ts +33 -33
  207. package/src/test-utils/factories/talkpilot/groups.ts +33 -33
  208. package/src/test-utils/factories/talkpilot/phone_numbers.ts +22 -22
  209. package/src/test-utils/factories/talkpilot/sessions.ts +35 -35
  210. package/src/test-utils/factories/websitalk/knowledgeDocuments.ts +29 -29
  211. package/src/test-utils/factories/websitalk/scanResources.ts +26 -26
  212. package/src/test-utils/factories/websitalk/scans.ts +29 -29
  213. package/src/test-utils/factories/websitalk/websiteUrls.ts +17 -17
  214. package/src/utils/date.utils.ts +126 -126
  215. package/src/utils/shared.types.ts +4 -4
  216. package/src/utils/validation.ts +23 -23
  217. package/src/websitalk/index.ts +18 -18
  218. package/src/websitalk/knowledgeDocuments/__tests__/knowledgeDocuments.spec.ts +186 -186
  219. package/src/websitalk/knowledgeDocuments/index.ts +3 -3
  220. package/src/websitalk/knowledgeDocuments/knowledgeDocuments.constants.ts +17 -17
  221. package/src/websitalk/knowledgeDocuments/knowledgeDocuments.getters.ts +78 -78
  222. package/src/websitalk/knowledgeDocuments/knowledgeDocuments.types.ts +62 -62
  223. package/src/websitalk/mongodb-client.ts +61 -61
  224. package/src/websitalk/scanResources/__tests__/scanResources.spec.ts +193 -136
  225. package/src/websitalk/scanResources/index.ts +4 -4
  226. package/src/websitalk/scanResources/scanResources.constants.ts +47 -47
  227. package/src/websitalk/scanResources/scanResources.getters.ts +102 -94
  228. package/src/websitalk/scanResources/scanResources.types.ts +90 -90
  229. package/src/websitalk/scanResources/scanResources.utils.ts +31 -28
  230. package/src/websitalk/scans/__tests__/scans.spec.ts +313 -313
  231. package/src/websitalk/scans/index.ts +4 -4
  232. package/src/websitalk/scans/scans.constants.ts +15 -15
  233. package/src/websitalk/scans/scans.getters.ts +166 -166
  234. package/src/websitalk/scans/scans.types.ts +54 -54
  235. package/src/websitalk/scans/scans.utils.ts +5 -5
  236. package/src/websitalk/websiteUrls/__tests__/websiteUrls.spec.ts +77 -77
  237. package/src/websitalk/websiteUrls/index.ts +2 -2
  238. package/src/websitalk/websiteUrls/websiteUrls.getters.ts +65 -65
  239. package/src/websitalk/websiteUrls/websiteUrls.types.ts +13 -13
  240. package/tsconfig.json +23 -23
@@ -1,668 +1,668 @@
1
- import { getCallsCollection } from "./calls.getters";
2
- import {
3
- HOURS_PER_DAY,
4
- STATS_MAX_TIME_MS,
5
- TREND_MONTH_BUCKET_MIN_MONTHS,
6
- TREND_WEEK_BUCKET_MIN_MONTHS,
7
- } from "./calls.constants";
8
- import {
9
- addDaysToDateStr,
10
- DAY_MS,
11
- dateAtNoonUtc,
12
- formatYmdInTz,
13
- getWeekdayInTz,
14
- getYmdInTz,
15
- incrementYmd,
16
- parseYmd,
17
- toUtcDate,
18
- ymdToStr,
19
- } from "../../utils/date.utils";
20
- import {
21
- findTicketSubjectRows,
22
- ticketStatsDateScopeFromYmd,
23
- } from "../../municipal/tickets/tickets.statistics.getters";
24
- import type { CityName } from "../../municipal/utils/types";
25
- import type {
26
- SubjectItem,
27
- TicketStatsDateScope,
28
- } from "../../municipal/tickets/tickets.types";
29
- import type {
30
- CallRoutingFilter,
31
- CallsHourlyBucket,
32
- CallsRoutingAggregation,
33
- CallsStatsFilter,
34
- CallsSummaryAggregation,
35
- CallsTrendBucket,
36
- ResolvedTimeBucket,
37
- } from "./calls.statistics.types";
38
-
39
- const emptySummary = (): CallsSummaryAggregation => ({
40
- totalCalls: 0,
41
- avgDurationSeconds: 0,
42
- openTickets: 0,
43
- draftTickets: 0,
44
- callsWithTickets: 0,
45
- });
46
-
47
- const emptyRouting = (): CallsRoutingAggregation => ({
48
- aiHandled: 0,
49
- transferred: 0,
50
- hijacked: 0,
51
- });
52
-
53
- const isTransferred = { $eq: ["$redirectedCall", true] };
54
-
55
- const isHijacked = {
56
- $and: [
57
- { $eq: ["$isConferenceCall", true] },
58
- { $ne: ["$redirectedCall", true] },
59
- ],
60
- };
61
-
62
- const countIf = (condition: Record<string, unknown>) => ({
63
- $sum: { $cond: [condition, 1, 0] },
64
- });
65
-
66
- const needsTicketStatusFilter = (
67
- filters?: CallsStatsFilter["ticketStatusFilters"],
68
- ) => {
69
- if (!filters?.length) return false;
70
- const hasOpened = filters.includes("opened");
71
- const hasNotOpened = filters.includes("not_opened");
72
- return hasOpened !== hasNotOpened;
73
- };
74
-
75
- const routingMatch = (routing?: CallRoutingFilter[]) => {
76
- if (!routing?.length) return null;
77
-
78
- const clauses: Record<string, unknown>[] = [];
79
- if (routing.includes("transferred")) clauses.push({ redirectedCall: true });
80
- if (routing.includes("hijacked")) {
81
- clauses.push({ isConferenceCall: true, redirectedCall: { $ne: true } });
82
- }
83
- if (routing.includes("ai_only")) {
84
- clauses.push({
85
- redirectedCall: { $ne: true },
86
- $or: [
87
- { isConferenceCall: { $ne: true } },
88
- { isConferenceCall: { $exists: false } },
89
- ],
90
- });
91
- }
92
-
93
- return clauses.length ? { $or: clauses } : null;
94
- };
95
-
96
- const ticketStatusMatch = (filter: CallsStatsFilter) => {
97
- if (!needsTicketStatusFilter(filter.ticketStatusFilters)) return null;
98
-
99
- const sids = [...(filter.callSidTicketCounts?.keys() ?? [])];
100
- const openedOnly = filter.ticketStatusFilters?.includes("opened");
101
- return { callSid: openedOnly ? { $in: sids } : { $nin: sids } };
102
- };
103
-
104
- // "HH:mm" → minute-of-day (0-1439), for the per-call time-of-day filter.
105
- const parseMinuteOfDay = (time?: string): number | undefined =>
106
- time == null
107
- ? undefined
108
- : Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
109
-
110
- const hourMatch = (
111
- filter: CallsStatsFilter,
112
- ): Record<string, unknown> | null => {
113
- const from = parseMinuteOfDay(filter.hourFrom);
114
- const to = parseMinuteOfDay(filter.hourTo);
115
- if (from == null && to == null) return null;
116
-
117
- if (from != null && to != null && from > to) {
118
- return {
119
- $or: [
120
- { minuteOfDay: { $gte: from }, dateLocal: { $lt: filter.endStr } },
121
- { minuteOfDay: { $lt: to }, dateLocal: { $gt: filter.startStr } },
122
- ],
123
- };
124
- }
125
-
126
- const minuteOfDay: Record<string, unknown> = {};
127
- if (from != null) minuteOfDay.$gte = from;
128
- if (to != null) minuteOfDay.$lt = to;
129
- return { minuteOfDay };
130
- };
131
-
132
- const createdAtMatchForFilter = (
133
- filter: CallsStatsFilter,
134
- ): { createdAt: { $gte: Date; $lte: Date } } => {
135
- const bounds = ticketStatsDateScopeFromYmd(
136
- filter.startStr,
137
- filter.endStr,
138
- filter.timezone,
139
- );
140
- return { createdAt: { $gte: bounds.from!, $lte: bounds.to! } };
141
- };
142
-
143
- const buildCallsMatchStages = (
144
- filter: CallsStatsFilter,
145
- ): Record<string, unknown>[] => {
146
- const hourRange = hourMatch(filter);
147
- const routing = routingMatch(filter.callRouting);
148
- const ticketMatch = ticketStatusMatch(filter);
149
-
150
- return [
151
- {
152
- $match: {
153
- clientId: filter.clientId,
154
- isIncomingCall: true,
155
- ...createdAtMatchForFilter(filter),
156
- },
157
- },
158
- {
159
- $addFields: {
160
- dateLocal: {
161
- $dateToString: {
162
- format: "%Y-%m-%d",
163
- date: "$createdAt",
164
- timezone: filter.timezone,
165
- },
166
- },
167
- hour: { $hour: { date: "$createdAt", timezone: filter.timezone } },
168
- minuteOfDay: {
169
- $add: [
170
- {
171
- $multiply: [
172
- { $hour: { date: "$createdAt", timezone: filter.timezone } },
173
- 60,
174
- ],
175
- },
176
- { $minute: { date: "$createdAt", timezone: filter.timezone } },
177
- ],
178
- },
179
- },
180
- },
181
- { $match: { dateLocal: { $gte: filter.startStr, $lte: filter.endStr } } },
182
- ...(hourRange ? [{ $match: hourRange }] : []),
183
- ...(routing ? [{ $match: routing }] : []),
184
- ...(ticketMatch ? [{ $match: ticketMatch }] : []),
185
- ];
186
- };
187
-
188
- const aggregateCalls = (
189
- filter: CallsStatsFilter,
190
- ...tail: Record<string, unknown>[]
191
- ) =>
192
- getCallsCollection().aggregate([...buildCallsMatchStages(filter), ...tail], {
193
- maxTimeMS: STATS_MAX_TIME_MS,
194
- });
195
-
196
- const trendBucketExpr = (bucket: ResolvedTimeBucket, timezone: string) =>
197
- bucket === "day"
198
- ? { bucket: "$dateLocal" }
199
- : {
200
- bucket: {
201
- $dateToString: {
202
- format: "%Y-%m-%d",
203
- date: {
204
- $dateTrunc: { date: "$createdAt", unit: bucket, timezone },
205
- },
206
- timezone,
207
- },
208
- },
209
- };
210
-
211
- const mapTrendRow = (row: {
212
- _id: unknown;
213
- totalCalls: number;
214
- openTickets: number;
215
- }): CallsTrendBucket => ({
216
- bucket: String(row._id),
217
- totalCalls: row.totalCalls,
218
- openTickets: row.openTickets,
219
- });
220
-
221
- export const rangeDurationDays = (startStr: string, endStr: string): number => {
222
- const start = toUtcDate(startStr).getTime();
223
- const end = toUtcDate(endStr).getTime();
224
- return Math.floor((end - start) / DAY_MS) + 1;
225
- };
226
-
227
- /**
228
- * Number of distinct calendar months the [startStr, endStr] range spans, inclusive.
229
- * Counts month boundaries rather than 30-day periods: e.g. Jan 31 -> Feb 1 returns 2.
230
- */
231
- export const rangeDurationMonths = (
232
- startStr: string,
233
- endStr: string,
234
- ): number => {
235
- const [sy, sm] = parseYmd(startStr);
236
- const [ey, em] = parseYmd(endStr);
237
- return (ey - sy) * 12 + (em - sm) + 1;
238
- };
239
-
240
- /**
241
- * Chooses how the client calls-over-time bar chart groups each column:
242
- * one bar per day, week, or calendar month. Short ranges stay daily; longer
243
- * ones switch to weekly or monthly so the chart does not show hundreds of bars.
244
- */
245
- export const resolveTimeBucketFromRange = (
246
- startStr: string,
247
- endStr: string,
248
- ): ResolvedTimeBucket => {
249
- const months = rangeDurationMonths(startStr, endStr);
250
- if (months > TREND_MONTH_BUCKET_MIN_MONTHS) return "month";
251
- if (months > TREND_WEEK_BUCKET_MIN_MONTHS) return "week";
252
- return "day";
253
- };
254
-
255
- const weekBucketKeyForDate = (dateStr: string, timezone: string): string => {
256
- const anchor = dateAtNoonUtc(dateStr);
257
- const weekStart = new Date(
258
- anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS,
259
- );
260
- return formatYmdInTz(weekStart, timezone);
261
- };
262
-
263
- const formatWeekBucketRange = (
264
- weekStartStr: string,
265
- clipStartStr: string,
266
- clipEndStr: string,
267
- ): string => {
268
- const weekEndStr = addDaysToDateStr(weekStartStr, 6);
269
- const start = weekStartStr < clipStartStr ? clipStartStr : weekStartStr;
270
- const end = weekEndStr > clipEndStr ? clipEndStr : weekEndStr;
271
- return `${start}/${end}`;
272
- };
273
-
274
- const monthBucketKey = (y: number, m: number): string =>
275
- `${y}-${String(m).padStart(2, "0")}-01`;
276
-
277
- const emptyTrendBucket = (bucket: string): CallsTrendBucket => ({
278
- bucket,
279
- totalCalls: 0,
280
- openTickets: 0,
281
- });
282
-
283
- const indexTrendRowsByBucket = (rows: CallsTrendBucket[]) =>
284
- new Map(rows.map((row) => [row.bucket, row]));
285
-
286
- const fillDailyTrendBuckets = (
287
- rows: CallsTrendBucket[],
288
- startStr: string,
289
- endStr: string,
290
- ): CallsTrendBucket[] => {
291
- const byBucket = indexTrendRowsByBucket(rows);
292
- const filled: CallsTrendBucket[] = [];
293
-
294
- for (
295
- let cursor = startStr;
296
- cursor <= endStr;
297
- cursor = ymdToStr(...incrementYmd(...parseYmd(cursor)))
298
- ) {
299
- filled.push(byBucket.get(cursor) ?? emptyTrendBucket(cursor));
300
- }
301
-
302
- return filled;
303
- };
304
-
305
- const fillMonthlyTrendBuckets = (
306
- rows: CallsTrendBucket[],
307
- startStr: string,
308
- endStr: string,
309
- timezone: string,
310
- ): CallsTrendBucket[] => {
311
- const byBucket = indexTrendRowsByBucket(rows);
312
- const startParts = getYmdInTz(dateAtNoonUtc(startStr), timezone);
313
- const endParts = getYmdInTz(dateAtNoonUtc(endStr), timezone);
314
-
315
- const filled: CallsTrendBucket[] = [];
316
- let { y, m } = startParts;
317
-
318
- while (y < endParts.y || (y === endParts.y && m <= endParts.m)) {
319
- const bucket = monthBucketKey(y, m);
320
- filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
321
- m += 1;
322
- if (m > 12) {
323
- m = 1;
324
- y += 1;
325
- }
326
- }
327
-
328
- return filled;
329
- };
330
-
331
- const fillWeeklyTrendBuckets = (
332
- rows: CallsTrendBucket[],
333
- startStr: string,
334
- endStr: string,
335
- timezone: string,
336
- ): CallsTrendBucket[] => {
337
- const byBucket = indexTrendRowsByBucket(rows);
338
- const filled: CallsTrendBucket[] = [];
339
- const seen = new Set<string>();
340
-
341
- for (
342
- let i = 0, days = rangeDurationDays(startStr, endStr);
343
- i < days;
344
- i += 1
345
- ) {
346
- const bucket = formatWeekBucketRange(
347
- weekBucketKeyForDate(addDaysToDateStr(startStr, i), timezone),
348
- startStr,
349
- endStr,
350
- );
351
- if (seen.has(bucket)) continue;
352
- seen.add(bucket);
353
- filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
354
- }
355
-
356
- return filled;
357
- };
358
-
359
- const fillTrendBuckets = (
360
- rows: CallsTrendBucket[],
361
- bucket: ResolvedTimeBucket,
362
- startStr: string,
363
- endStr: string,
364
- timezone: string,
365
- ): CallsTrendBucket[] => {
366
- switch (bucket) {
367
- case "day":
368
- return fillDailyTrendBuckets(rows, startStr, endStr);
369
- case "week":
370
- return fillWeeklyTrendBuckets(rows, startStr, endStr, timezone);
371
- case "month":
372
- return fillMonthlyTrendBuckets(rows, startStr, endStr, timezone);
373
- default: {
374
- return fillDailyTrendBuckets(rows, startStr, endStr);
375
- }
376
- }
377
- };
378
-
379
- const resolveHourRange = (
380
- filter: CallsStatsFilter,
381
- ): { hourFrom: number; hourTo: number } => {
382
- const from = parseMinuteOfDay(filter.hourFrom);
383
- const to = parseMinuteOfDay(filter.hourTo);
384
- if (from == null && to == null)
385
- return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 };
386
- return {
387
- hourFrom: from == null ? 0 : Math.floor(from / 60),
388
- // hourTo is exclusive: last populated bucket is the hour holding minute (to - 1).
389
- hourTo: to == null ? HOURS_PER_DAY - 1 : Math.floor((to - 1) / 60),
390
- };
391
- };
392
-
393
- const roundOneDecimal = (value: number): number => Math.round(value * 10) / 10;
394
-
395
- const toHourlyDistribution = (
396
- rows: { _id: unknown; totalCalls: number }[],
397
- hourRange: { hourFrom: number; hourTo: number },
398
- daysInRange: number,
399
- ): CallsHourlyBucket[] => {
400
- const counts = new Map(rows.map((r) => [Number(r._id), r.totalCalls]));
401
- const { hourFrom, hourTo } = hourRange;
402
- const buckets: CallsHourlyBucket[] = [];
403
-
404
- const pushHour = (hour: number) => {
405
- const totalCalls = counts.get(hour) ?? 0;
406
- buckets.push({
407
- hour,
408
- totalCalls,
409
- avgCalls: roundOneDecimal(daysInRange > 0 ? totalCalls / daysInRange : 0),
410
- });
411
- };
412
-
413
- if (hourFrom <= hourTo) {
414
- for (let hour = hourFrom; hour <= hourTo; hour += 1) pushHour(hour);
415
- } else {
416
- // Wraps past midnight: hourFrom..23, then 0..hourTo.
417
- for (let hour = hourFrom; hour < HOURS_PER_DAY; hour += 1) pushHour(hour);
418
- for (let hour = 0; hour <= hourTo; hour += 1) pushHour(hour);
419
- }
420
-
421
- return buckets;
422
- };
423
-
424
- export const aggregateCallsSummary = async (
425
- filter: CallsStatsFilter,
426
- ): Promise<CallsSummaryAggregation> => {
427
- const hasTickets = Boolean(
428
- filter.callSidTicketCounts?.size || filter.callSidDraftTicketCounts?.size,
429
- );
430
- const hasStatusFilter = needsTicketStatusFilter(filter.ticketStatusFilters);
431
-
432
- if (!hasTickets && !hasStatusFilter) {
433
- const result = await aggregateCalls(filter, {
434
- $group: {
435
- _id: null,
436
- totalCalls: { $sum: 1 },
437
- avgDurationSeconds: { $avg: "$callLength" },
438
- },
439
- }).next();
440
- if (!result) return emptySummary();
441
- return {
442
- totalCalls: result.totalCalls,
443
- avgDurationSeconds: roundOneDecimal(result.avgDurationSeconds ?? 0),
444
- openTickets: 0,
445
- draftTickets: 0,
446
- callsWithTickets: 0,
447
- };
448
- }
449
-
450
- // Single pass: project per-call data, handle ticket status filter and counting in JS.
451
- // Avoids embedding callSidsWithTickets inside any MongoDB pipeline stage.
452
- const rows = await aggregateCalls(
453
- { ...filter, ticketStatusFilters: undefined },
454
- { $project: { _id: 0, callSid: 1, callLength: 1 } },
455
- ).toArray();
456
-
457
- const ticketMap = filter.callSidTicketCounts ?? new Map<string, number>();
458
- const draftMap = filter.callSidDraftTicketCounts ?? new Map<string, number>();
459
- const openedOnly =
460
- hasStatusFilter && filter.ticketStatusFilters!.includes("opened");
461
- const notOpenedOnly =
462
- hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened");
463
-
464
- let totalCalls = 0;
465
- let totalDuration = 0;
466
- let openTickets = 0;
467
- let draftTickets = 0;
468
- let callsWithTickets = 0;
469
-
470
- for (const row of rows) {
471
- const sid = row.callSid as string;
472
- const hasTicket = ticketMap.has(sid);
473
- if (openedOnly && !hasTicket) continue;
474
- if (notOpenedOnly && hasTicket) continue;
475
- totalCalls += 1;
476
- totalDuration += (row.callLength as number | undefined) ?? 0;
477
- openTickets += ticketMap.get(sid) ?? 0;
478
- if (ticketMap.has(sid)) callsWithTickets += 1;
479
- draftTickets += draftMap.get(sid) ?? 0;
480
- }
481
-
482
- if (!totalCalls) return emptySummary();
483
-
484
- return {
485
- totalCalls,
486
- avgDurationSeconds: roundOneDecimal(totalDuration / totalCalls),
487
- openTickets,
488
- draftTickets,
489
- callsWithTickets,
490
- };
491
- };
492
-
493
- export const aggregateCallsTrend = async (
494
- filter: CallsStatsFilter,
495
- bucket: ResolvedTimeBucket,
496
- ): Promise<CallsTrendBucket[]> => {
497
- const ticketMap = filter.callSidTicketCounts?.size
498
- ? filter.callSidTicketCounts
499
- : null;
500
-
501
- if (ticketMap) {
502
- const rows = await aggregateCalls(
503
- { ...filter, ticketStatusFilters: undefined },
504
- { $addFields: trendBucketExpr(bucket, filter.timezone) },
505
- { $project: { _id: 0, bucket: 1, callSid: 1 } },
506
- ).toArray();
507
-
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
- >();
519
- for (const row of rows) {
520
- const sid = row.callSid as string;
521
- const hasTicket = ticketMap.has(sid);
522
- if (openedOnly && !hasTicket) continue;
523
- if (notOpenedOnly && hasTicket) continue;
524
- const label =
525
- bucket === "week"
526
- ? formatWeekBucketRange(
527
- String(row.bucket),
528
- filter.startStr,
529
- filter.endStr,
530
- )
531
- : String(row.bucket);
532
- const cur = byBucket.get(label) ?? { totalCalls: 0, openTickets: 0 };
533
- cur.totalCalls += 1;
534
- cur.openTickets += ticketMap.get(sid) ?? 0;
535
- byBucket.set(label, cur);
536
- }
537
-
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
- );
550
- }
551
-
552
- const rows = await aggregateCalls(
553
- filter,
554
- { $addFields: trendBucketExpr(bucket, filter.timezone) },
555
- { $group: { _id: "$bucket", totalCalls: { $sum: 1 } } },
556
- { $sort: { _id: 1 } },
557
- ).toArray();
558
-
559
- const mappedRows =
560
- bucket === "week"
561
- ? rows.map((row) => ({
562
- bucket: formatWeekBucketRange(
563
- String(row._id),
564
- filter.startStr,
565
- filter.endStr,
566
- ),
567
- totalCalls: row.totalCalls as number,
568
- openTickets: 0,
569
- }))
570
- : rows.map((row) =>
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
- );
585
- };
586
-
587
- export const aggregateCallsHourlyByRange = async (
588
- filter: CallsStatsFilter,
589
- ): Promise<CallsHourlyBucket[]> => {
590
- const rows = await aggregateCalls(
591
- filter,
592
- { $group: { _id: "$hour", totalCalls: { $sum: 1 } } },
593
- { $sort: { _id: 1 } },
594
- ).toArray();
595
-
596
- return toHourlyDistribution(
597
- rows.map((r) => ({ _id: r._id, totalCalls: r.totalCalls as number })),
598
- resolveHourRange(filter),
599
- rangeDurationDays(filter.startStr, filter.endStr),
600
- );
601
- };
602
-
603
- export const aggregateCallsRouting = async (
604
- filter: CallsStatsFilter,
605
- ): Promise<CallsRoutingAggregation> => {
606
- const result = await aggregateCalls(
607
- filter,
608
- {
609
- $group: {
610
- _id: null,
611
- transferred: countIf(isTransferred),
612
- hijacked: countIf(isHijacked),
613
- totalCalls: { $sum: 1 },
614
- },
615
- },
616
- {
617
- $addFields: {
618
- aiHandled: {
619
- $subtract: ["$totalCalls", { $add: ["$transferred", "$hijacked"] }],
620
- },
621
- },
622
- },
623
- ).next();
624
-
625
- if (!result) return emptyRouting();
626
-
627
- return {
628
- aiHandled: result.aiHandled,
629
- transferred: result.transferred,
630
- hijacked: result.hijacked,
631
- };
632
- };
633
-
634
- export const findFilteredCallSids = async (
635
- filter: CallsStatsFilter,
636
- ): Promise<string[]> => {
637
- const rows = await aggregateCalls(filter, {
638
- $group: { _id: "$callSid" },
639
- }).toArray();
640
- return rows.map((r) => String(r._id)).filter(Boolean);
641
- };
642
-
643
- /**
644
- * "Subjects" panel: ticket count per subject, for calls passing the filters.
645
- * Calls and tickets are in separate DBs, so we join in code — filtered call IDs
646
- * from calls, subjects from tickets. Returns the full list sorted most-common
647
- * first; callers slice if they want a top-N.
648
- */
649
- export const findSubjectsByCityAndDateRange = async (
650
- cityName: CityName,
651
- filter: CallsStatsFilter,
652
- dateScope: TicketStatsDateScope,
653
- ): Promise<SubjectItem[]> => {
654
- const filteredCallSids = new Set(await findFilteredCallSids(filter));
655
- if (!filteredCallSids.size) return [];
656
-
657
- const rows = await findTicketSubjectRows(cityName, dateScope);
658
-
659
- const counts = new Map<string, number>();
660
- for (const row of rows) {
661
- if (!filteredCallSids.has(row.callSid)) continue;
662
- counts.set(row.subject, (counts.get(row.subject) ?? 0) + 1);
663
- }
664
-
665
- return [...counts.entries()]
666
- .map(([subject, count]) => ({ subject, count }))
667
- .sort((a, b) => b.count - a.count || a.subject.localeCompare(b.subject));
668
- };
1
+ import { getCallsCollection } from "./calls.getters";
2
+ import {
3
+ HOURS_PER_DAY,
4
+ STATS_MAX_TIME_MS,
5
+ TREND_MONTH_BUCKET_MIN_MONTHS,
6
+ TREND_WEEK_BUCKET_MIN_MONTHS,
7
+ } from "./calls.constants";
8
+ import {
9
+ addDaysToDateStr,
10
+ DAY_MS,
11
+ dateAtNoonUtc,
12
+ formatYmdInTz,
13
+ getWeekdayInTz,
14
+ getYmdInTz,
15
+ incrementYmd,
16
+ parseYmd,
17
+ toUtcDate,
18
+ ymdToStr,
19
+ } from "../../utils/date.utils";
20
+ import {
21
+ findTicketSubjectRows,
22
+ ticketStatsDateScopeFromYmd,
23
+ } from "../../municipal/tickets/tickets.statistics.getters";
24
+ import type { CityName } from "../../municipal/utils/types";
25
+ import type {
26
+ SubjectItem,
27
+ TicketStatsDateScope,
28
+ } from "../../municipal/tickets/tickets.types";
29
+ import type {
30
+ CallRoutingFilter,
31
+ CallsHourlyBucket,
32
+ CallsRoutingAggregation,
33
+ CallsStatsFilter,
34
+ CallsSummaryAggregation,
35
+ CallsTrendBucket,
36
+ ResolvedTimeBucket,
37
+ } from "./calls.statistics.types";
38
+
39
+ const emptySummary = (): CallsSummaryAggregation => ({
40
+ totalCalls: 0,
41
+ avgDurationSeconds: 0,
42
+ openTickets: 0,
43
+ draftTickets: 0,
44
+ callsWithTickets: 0,
45
+ });
46
+
47
+ const emptyRouting = (): CallsRoutingAggregation => ({
48
+ aiHandled: 0,
49
+ transferred: 0,
50
+ hijacked: 0,
51
+ });
52
+
53
+ const isTransferred = { $eq: ["$redirectedCall", true] };
54
+
55
+ const isHijacked = {
56
+ $and: [
57
+ { $eq: ["$isConferenceCall", true] },
58
+ { $ne: ["$redirectedCall", true] },
59
+ ],
60
+ };
61
+
62
+ const countIf = (condition: Record<string, unknown>) => ({
63
+ $sum: { $cond: [condition, 1, 0] },
64
+ });
65
+
66
+ const needsTicketStatusFilter = (
67
+ filters?: CallsStatsFilter["ticketStatusFilters"],
68
+ ) => {
69
+ if (!filters?.length) return false;
70
+ const hasOpened = filters.includes("opened");
71
+ const hasNotOpened = filters.includes("not_opened");
72
+ return hasOpened !== hasNotOpened;
73
+ };
74
+
75
+ const routingMatch = (routing?: CallRoutingFilter[]) => {
76
+ if (!routing?.length) return null;
77
+
78
+ const clauses: Record<string, unknown>[] = [];
79
+ if (routing.includes("transferred")) clauses.push({ redirectedCall: true });
80
+ if (routing.includes("hijacked")) {
81
+ clauses.push({ isConferenceCall: true, redirectedCall: { $ne: true } });
82
+ }
83
+ if (routing.includes("ai_only")) {
84
+ clauses.push({
85
+ redirectedCall: { $ne: true },
86
+ $or: [
87
+ { isConferenceCall: { $ne: true } },
88
+ { isConferenceCall: { $exists: false } },
89
+ ],
90
+ });
91
+ }
92
+
93
+ return clauses.length ? { $or: clauses } : null;
94
+ };
95
+
96
+ const ticketStatusMatch = (filter: CallsStatsFilter) => {
97
+ if (!needsTicketStatusFilter(filter.ticketStatusFilters)) return null;
98
+
99
+ const sids = [...(filter.callSidTicketCounts?.keys() ?? [])];
100
+ const openedOnly = filter.ticketStatusFilters?.includes("opened");
101
+ return { callSid: openedOnly ? { $in: sids } : { $nin: sids } };
102
+ };
103
+
104
+ // "HH:mm" → minute-of-day (0-1439), for the per-call time-of-day filter.
105
+ const parseMinuteOfDay = (time?: string): number | undefined =>
106
+ time == null
107
+ ? undefined
108
+ : Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
109
+
110
+ const hourMatch = (
111
+ filter: CallsStatsFilter,
112
+ ): Record<string, unknown> | null => {
113
+ const from = parseMinuteOfDay(filter.hourFrom);
114
+ const to = parseMinuteOfDay(filter.hourTo);
115
+ if (from == null && to == null) return null;
116
+
117
+ if (from != null && to != null && from > to) {
118
+ return {
119
+ $or: [
120
+ { minuteOfDay: { $gte: from }, dateLocal: { $lt: filter.endStr } },
121
+ { minuteOfDay: { $lt: to }, dateLocal: { $gt: filter.startStr } },
122
+ ],
123
+ };
124
+ }
125
+
126
+ const minuteOfDay: Record<string, unknown> = {};
127
+ if (from != null) minuteOfDay.$gte = from;
128
+ if (to != null) minuteOfDay.$lt = to;
129
+ return { minuteOfDay };
130
+ };
131
+
132
+ const createdAtMatchForFilter = (
133
+ filter: CallsStatsFilter,
134
+ ): { createdAt: { $gte: Date; $lte: Date } } => {
135
+ const bounds = ticketStatsDateScopeFromYmd(
136
+ filter.startStr,
137
+ filter.endStr,
138
+ filter.timezone,
139
+ );
140
+ return { createdAt: { $gte: bounds.from!, $lte: bounds.to! } };
141
+ };
142
+
143
+ const buildCallsMatchStages = (
144
+ filter: CallsStatsFilter,
145
+ ): Record<string, unknown>[] => {
146
+ const hourRange = hourMatch(filter);
147
+ const routing = routingMatch(filter.callRouting);
148
+ const ticketMatch = ticketStatusMatch(filter);
149
+
150
+ return [
151
+ {
152
+ $match: {
153
+ clientId: filter.clientId,
154
+ isIncomingCall: true,
155
+ ...createdAtMatchForFilter(filter),
156
+ },
157
+ },
158
+ {
159
+ $addFields: {
160
+ dateLocal: {
161
+ $dateToString: {
162
+ format: "%Y-%m-%d",
163
+ date: "$createdAt",
164
+ timezone: filter.timezone,
165
+ },
166
+ },
167
+ hour: { $hour: { date: "$createdAt", timezone: filter.timezone } },
168
+ minuteOfDay: {
169
+ $add: [
170
+ {
171
+ $multiply: [
172
+ { $hour: { date: "$createdAt", timezone: filter.timezone } },
173
+ 60,
174
+ ],
175
+ },
176
+ { $minute: { date: "$createdAt", timezone: filter.timezone } },
177
+ ],
178
+ },
179
+ },
180
+ },
181
+ { $match: { dateLocal: { $gte: filter.startStr, $lte: filter.endStr } } },
182
+ ...(hourRange ? [{ $match: hourRange }] : []),
183
+ ...(routing ? [{ $match: routing }] : []),
184
+ ...(ticketMatch ? [{ $match: ticketMatch }] : []),
185
+ ];
186
+ };
187
+
188
+ const aggregateCalls = (
189
+ filter: CallsStatsFilter,
190
+ ...tail: Record<string, unknown>[]
191
+ ) =>
192
+ getCallsCollection().aggregate([...buildCallsMatchStages(filter), ...tail], {
193
+ maxTimeMS: STATS_MAX_TIME_MS,
194
+ });
195
+
196
+ const trendBucketExpr = (bucket: ResolvedTimeBucket, timezone: string) =>
197
+ bucket === "day"
198
+ ? { bucket: "$dateLocal" }
199
+ : {
200
+ bucket: {
201
+ $dateToString: {
202
+ format: "%Y-%m-%d",
203
+ date: {
204
+ $dateTrunc: { date: "$createdAt", unit: bucket, timezone },
205
+ },
206
+ timezone,
207
+ },
208
+ },
209
+ };
210
+
211
+ const mapTrendRow = (row: {
212
+ _id: unknown;
213
+ totalCalls: number;
214
+ openTickets: number;
215
+ }): CallsTrendBucket => ({
216
+ bucket: String(row._id),
217
+ totalCalls: row.totalCalls,
218
+ openTickets: row.openTickets,
219
+ });
220
+
221
+ export const rangeDurationDays = (startStr: string, endStr: string): number => {
222
+ const start = toUtcDate(startStr).getTime();
223
+ const end = toUtcDate(endStr).getTime();
224
+ return Math.floor((end - start) / DAY_MS) + 1;
225
+ };
226
+
227
+ /**
228
+ * Number of distinct calendar months the [startStr, endStr] range spans, inclusive.
229
+ * Counts month boundaries rather than 30-day periods: e.g. Jan 31 -> Feb 1 returns 2.
230
+ */
231
+ export const rangeDurationMonths = (
232
+ startStr: string,
233
+ endStr: string,
234
+ ): number => {
235
+ const [sy, sm] = parseYmd(startStr);
236
+ const [ey, em] = parseYmd(endStr);
237
+ return (ey - sy) * 12 + (em - sm) + 1;
238
+ };
239
+
240
+ /**
241
+ * Chooses how the client calls-over-time bar chart groups each column:
242
+ * one bar per day, week, or calendar month. Short ranges stay daily; longer
243
+ * ones switch to weekly or monthly so the chart does not show hundreds of bars.
244
+ */
245
+ export const resolveTimeBucketFromRange = (
246
+ startStr: string,
247
+ endStr: string,
248
+ ): ResolvedTimeBucket => {
249
+ const months = rangeDurationMonths(startStr, endStr);
250
+ if (months > TREND_MONTH_BUCKET_MIN_MONTHS) return "month";
251
+ if (months > TREND_WEEK_BUCKET_MIN_MONTHS) return "week";
252
+ return "day";
253
+ };
254
+
255
+ const weekBucketKeyForDate = (dateStr: string, timezone: string): string => {
256
+ const anchor = dateAtNoonUtc(dateStr);
257
+ const weekStart = new Date(
258
+ anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS,
259
+ );
260
+ return formatYmdInTz(weekStart, timezone);
261
+ };
262
+
263
+ const formatWeekBucketRange = (
264
+ weekStartStr: string,
265
+ clipStartStr: string,
266
+ clipEndStr: string,
267
+ ): string => {
268
+ const weekEndStr = addDaysToDateStr(weekStartStr, 6);
269
+ const start = weekStartStr < clipStartStr ? clipStartStr : weekStartStr;
270
+ const end = weekEndStr > clipEndStr ? clipEndStr : weekEndStr;
271
+ return `${start}/${end}`;
272
+ };
273
+
274
+ const monthBucketKey = (y: number, m: number): string =>
275
+ `${y}-${String(m).padStart(2, "0")}-01`;
276
+
277
+ const emptyTrendBucket = (bucket: string): CallsTrendBucket => ({
278
+ bucket,
279
+ totalCalls: 0,
280
+ openTickets: 0,
281
+ });
282
+
283
+ const indexTrendRowsByBucket = (rows: CallsTrendBucket[]) =>
284
+ new Map(rows.map((row) => [row.bucket, row]));
285
+
286
+ const fillDailyTrendBuckets = (
287
+ rows: CallsTrendBucket[],
288
+ startStr: string,
289
+ endStr: string,
290
+ ): CallsTrendBucket[] => {
291
+ const byBucket = indexTrendRowsByBucket(rows);
292
+ const filled: CallsTrendBucket[] = [];
293
+
294
+ for (
295
+ let cursor = startStr;
296
+ cursor <= endStr;
297
+ cursor = ymdToStr(...incrementYmd(...parseYmd(cursor)))
298
+ ) {
299
+ filled.push(byBucket.get(cursor) ?? emptyTrendBucket(cursor));
300
+ }
301
+
302
+ return filled;
303
+ };
304
+
305
+ const fillMonthlyTrendBuckets = (
306
+ rows: CallsTrendBucket[],
307
+ startStr: string,
308
+ endStr: string,
309
+ timezone: string,
310
+ ): CallsTrendBucket[] => {
311
+ const byBucket = indexTrendRowsByBucket(rows);
312
+ const startParts = getYmdInTz(dateAtNoonUtc(startStr), timezone);
313
+ const endParts = getYmdInTz(dateAtNoonUtc(endStr), timezone);
314
+
315
+ const filled: CallsTrendBucket[] = [];
316
+ let { y, m } = startParts;
317
+
318
+ while (y < endParts.y || (y === endParts.y && m <= endParts.m)) {
319
+ const bucket = monthBucketKey(y, m);
320
+ filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
321
+ m += 1;
322
+ if (m > 12) {
323
+ m = 1;
324
+ y += 1;
325
+ }
326
+ }
327
+
328
+ return filled;
329
+ };
330
+
331
+ const fillWeeklyTrendBuckets = (
332
+ rows: CallsTrendBucket[],
333
+ startStr: string,
334
+ endStr: string,
335
+ timezone: string,
336
+ ): CallsTrendBucket[] => {
337
+ const byBucket = indexTrendRowsByBucket(rows);
338
+ const filled: CallsTrendBucket[] = [];
339
+ const seen = new Set<string>();
340
+
341
+ for (
342
+ let i = 0, days = rangeDurationDays(startStr, endStr);
343
+ i < days;
344
+ i += 1
345
+ ) {
346
+ const bucket = formatWeekBucketRange(
347
+ weekBucketKeyForDate(addDaysToDateStr(startStr, i), timezone),
348
+ startStr,
349
+ endStr,
350
+ );
351
+ if (seen.has(bucket)) continue;
352
+ seen.add(bucket);
353
+ filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
354
+ }
355
+
356
+ return filled;
357
+ };
358
+
359
+ const fillTrendBuckets = (
360
+ rows: CallsTrendBucket[],
361
+ bucket: ResolvedTimeBucket,
362
+ startStr: string,
363
+ endStr: string,
364
+ timezone: string,
365
+ ): CallsTrendBucket[] => {
366
+ switch (bucket) {
367
+ case "day":
368
+ return fillDailyTrendBuckets(rows, startStr, endStr);
369
+ case "week":
370
+ return fillWeeklyTrendBuckets(rows, startStr, endStr, timezone);
371
+ case "month":
372
+ return fillMonthlyTrendBuckets(rows, startStr, endStr, timezone);
373
+ default: {
374
+ return fillDailyTrendBuckets(rows, startStr, endStr);
375
+ }
376
+ }
377
+ };
378
+
379
+ const resolveHourRange = (
380
+ filter: CallsStatsFilter,
381
+ ): { hourFrom: number; hourTo: number } => {
382
+ const from = parseMinuteOfDay(filter.hourFrom);
383
+ const to = parseMinuteOfDay(filter.hourTo);
384
+ if (from == null && to == null)
385
+ return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 };
386
+ return {
387
+ hourFrom: from == null ? 0 : Math.floor(from / 60),
388
+ // hourTo is exclusive: last populated bucket is the hour holding minute (to - 1).
389
+ hourTo: to == null ? HOURS_PER_DAY - 1 : Math.floor((to - 1) / 60),
390
+ };
391
+ };
392
+
393
+ const roundOneDecimal = (value: number): number => Math.round(value * 10) / 10;
394
+
395
+ const toHourlyDistribution = (
396
+ rows: { _id: unknown; totalCalls: number }[],
397
+ hourRange: { hourFrom: number; hourTo: number },
398
+ daysInRange: number,
399
+ ): CallsHourlyBucket[] => {
400
+ const counts = new Map(rows.map((r) => [Number(r._id), r.totalCalls]));
401
+ const { hourFrom, hourTo } = hourRange;
402
+ const buckets: CallsHourlyBucket[] = [];
403
+
404
+ const pushHour = (hour: number) => {
405
+ const totalCalls = counts.get(hour) ?? 0;
406
+ buckets.push({
407
+ hour,
408
+ totalCalls,
409
+ avgCalls: roundOneDecimal(daysInRange > 0 ? totalCalls / daysInRange : 0),
410
+ });
411
+ };
412
+
413
+ if (hourFrom <= hourTo) {
414
+ for (let hour = hourFrom; hour <= hourTo; hour += 1) pushHour(hour);
415
+ } else {
416
+ // Wraps past midnight: hourFrom..23, then 0..hourTo.
417
+ for (let hour = hourFrom; hour < HOURS_PER_DAY; hour += 1) pushHour(hour);
418
+ for (let hour = 0; hour <= hourTo; hour += 1) pushHour(hour);
419
+ }
420
+
421
+ return buckets;
422
+ };
423
+
424
+ export const aggregateCallsSummary = async (
425
+ filter: CallsStatsFilter,
426
+ ): Promise<CallsSummaryAggregation> => {
427
+ const hasTickets = Boolean(
428
+ filter.callSidTicketCounts?.size || filter.callSidDraftTicketCounts?.size,
429
+ );
430
+ const hasStatusFilter = needsTicketStatusFilter(filter.ticketStatusFilters);
431
+
432
+ if (!hasTickets && !hasStatusFilter) {
433
+ const result = await aggregateCalls(filter, {
434
+ $group: {
435
+ _id: null,
436
+ totalCalls: { $sum: 1 },
437
+ avgDurationSeconds: { $avg: "$callLength" },
438
+ },
439
+ }).next();
440
+ if (!result) return emptySummary();
441
+ return {
442
+ totalCalls: result.totalCalls,
443
+ avgDurationSeconds: roundOneDecimal(result.avgDurationSeconds ?? 0),
444
+ openTickets: 0,
445
+ draftTickets: 0,
446
+ callsWithTickets: 0,
447
+ };
448
+ }
449
+
450
+ // Single pass: project per-call data, handle ticket status filter and counting in JS.
451
+ // Avoids embedding callSidsWithTickets inside any MongoDB pipeline stage.
452
+ const rows = await aggregateCalls(
453
+ { ...filter, ticketStatusFilters: undefined },
454
+ { $project: { _id: 0, callSid: 1, callLength: 1 } },
455
+ ).toArray();
456
+
457
+ const ticketMap = filter.callSidTicketCounts ?? new Map<string, number>();
458
+ const draftMap = filter.callSidDraftTicketCounts ?? new Map<string, number>();
459
+ const openedOnly =
460
+ hasStatusFilter && filter.ticketStatusFilters!.includes("opened");
461
+ const notOpenedOnly =
462
+ hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened");
463
+
464
+ let totalCalls = 0;
465
+ let totalDuration = 0;
466
+ let openTickets = 0;
467
+ let draftTickets = 0;
468
+ let callsWithTickets = 0;
469
+
470
+ for (const row of rows) {
471
+ const sid = row.callSid as string;
472
+ const hasTicket = ticketMap.has(sid);
473
+ if (openedOnly && !hasTicket) continue;
474
+ if (notOpenedOnly && hasTicket) continue;
475
+ totalCalls += 1;
476
+ totalDuration += (row.callLength as number | undefined) ?? 0;
477
+ openTickets += ticketMap.get(sid) ?? 0;
478
+ if (ticketMap.has(sid)) callsWithTickets += 1;
479
+ draftTickets += draftMap.get(sid) ?? 0;
480
+ }
481
+
482
+ if (!totalCalls) return emptySummary();
483
+
484
+ return {
485
+ totalCalls,
486
+ avgDurationSeconds: roundOneDecimal(totalDuration / totalCalls),
487
+ openTickets,
488
+ draftTickets,
489
+ callsWithTickets,
490
+ };
491
+ };
492
+
493
+ export const aggregateCallsTrend = async (
494
+ filter: CallsStatsFilter,
495
+ bucket: ResolvedTimeBucket,
496
+ ): Promise<CallsTrendBucket[]> => {
497
+ const ticketMap = filter.callSidTicketCounts?.size
498
+ ? filter.callSidTicketCounts
499
+ : null;
500
+
501
+ if (ticketMap) {
502
+ const rows = await aggregateCalls(
503
+ { ...filter, ticketStatusFilters: undefined },
504
+ { $addFields: trendBucketExpr(bucket, filter.timezone) },
505
+ { $project: { _id: 0, bucket: 1, callSid: 1 } },
506
+ ).toArray();
507
+
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
+ >();
519
+ for (const row of rows) {
520
+ const sid = row.callSid as string;
521
+ const hasTicket = ticketMap.has(sid);
522
+ if (openedOnly && !hasTicket) continue;
523
+ if (notOpenedOnly && hasTicket) continue;
524
+ const label =
525
+ bucket === "week"
526
+ ? formatWeekBucketRange(
527
+ String(row.bucket),
528
+ filter.startStr,
529
+ filter.endStr,
530
+ )
531
+ : String(row.bucket);
532
+ const cur = byBucket.get(label) ?? { totalCalls: 0, openTickets: 0 };
533
+ cur.totalCalls += 1;
534
+ cur.openTickets += ticketMap.get(sid) ?? 0;
535
+ byBucket.set(label, cur);
536
+ }
537
+
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
+ );
550
+ }
551
+
552
+ const rows = await aggregateCalls(
553
+ filter,
554
+ { $addFields: trendBucketExpr(bucket, filter.timezone) },
555
+ { $group: { _id: "$bucket", totalCalls: { $sum: 1 } } },
556
+ { $sort: { _id: 1 } },
557
+ ).toArray();
558
+
559
+ const mappedRows =
560
+ bucket === "week"
561
+ ? rows.map((row) => ({
562
+ bucket: formatWeekBucketRange(
563
+ String(row._id),
564
+ filter.startStr,
565
+ filter.endStr,
566
+ ),
567
+ totalCalls: row.totalCalls as number,
568
+ openTickets: 0,
569
+ }))
570
+ : rows.map((row) =>
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
+ );
585
+ };
586
+
587
+ export const aggregateCallsHourlyByRange = async (
588
+ filter: CallsStatsFilter,
589
+ ): Promise<CallsHourlyBucket[]> => {
590
+ const rows = await aggregateCalls(
591
+ filter,
592
+ { $group: { _id: "$hour", totalCalls: { $sum: 1 } } },
593
+ { $sort: { _id: 1 } },
594
+ ).toArray();
595
+
596
+ return toHourlyDistribution(
597
+ rows.map((r) => ({ _id: r._id, totalCalls: r.totalCalls as number })),
598
+ resolveHourRange(filter),
599
+ rangeDurationDays(filter.startStr, filter.endStr),
600
+ );
601
+ };
602
+
603
+ export const aggregateCallsRouting = async (
604
+ filter: CallsStatsFilter,
605
+ ): Promise<CallsRoutingAggregation> => {
606
+ const result = await aggregateCalls(
607
+ filter,
608
+ {
609
+ $group: {
610
+ _id: null,
611
+ transferred: countIf(isTransferred),
612
+ hijacked: countIf(isHijacked),
613
+ totalCalls: { $sum: 1 },
614
+ },
615
+ },
616
+ {
617
+ $addFields: {
618
+ aiHandled: {
619
+ $subtract: ["$totalCalls", { $add: ["$transferred", "$hijacked"] }],
620
+ },
621
+ },
622
+ },
623
+ ).next();
624
+
625
+ if (!result) return emptyRouting();
626
+
627
+ return {
628
+ aiHandled: result.aiHandled,
629
+ transferred: result.transferred,
630
+ hijacked: result.hijacked,
631
+ };
632
+ };
633
+
634
+ export const findFilteredCallSids = async (
635
+ filter: CallsStatsFilter,
636
+ ): Promise<string[]> => {
637
+ const rows = await aggregateCalls(filter, {
638
+ $group: { _id: "$callSid" },
639
+ }).toArray();
640
+ return rows.map((r) => String(r._id)).filter(Boolean);
641
+ };
642
+
643
+ /**
644
+ * "Subjects" panel: ticket count per subject, for calls passing the filters.
645
+ * Calls and tickets are in separate DBs, so we join in code — filtered call IDs
646
+ * from calls, subjects from tickets. Returns the full list sorted most-common
647
+ * first; callers slice if they want a top-N.
648
+ */
649
+ export const findSubjectsByCityAndDateRange = async (
650
+ cityName: CityName,
651
+ filter: CallsStatsFilter,
652
+ dateScope: TicketStatsDateScope,
653
+ ): Promise<SubjectItem[]> => {
654
+ const filteredCallSids = new Set(await findFilteredCallSids(filter));
655
+ if (!filteredCallSids.size) return [];
656
+
657
+ const rows = await findTicketSubjectRows(cityName, dateScope);
658
+
659
+ const counts = new Map<string, number>();
660
+ for (const row of rows) {
661
+ if (!filteredCallSids.has(row.callSid)) continue;
662
+ counts.set(row.subject, (counts.get(row.subject) ?? 0) + 1);
663
+ }
664
+
665
+ return [...counts.entries()]
666
+ .map(([subject, count]) => ({ subject, count }))
667
+ .sort((a, b) => b.count - a.count || a.subject.localeCompare(b.subject));
668
+ };