@talkpilot/core-db 1.2.1 → 1.3.0

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 (147) hide show
  1. package/README.md +117 -108
  2. package/README_OLD.md +160 -0
  3. package/dist/municipal/tickets/index.d.ts +2 -1
  4. package/dist/municipal/tickets/index.d.ts.map +1 -1
  5. package/dist/municipal/tickets/index.js +1 -0
  6. package/dist/municipal/tickets/index.js.map +1 -1
  7. package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
  8. package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
  9. package/dist/municipal/tickets/tickets.constants.js +10 -0
  10. package/dist/municipal/tickets/tickets.constants.js.map +1 -0
  11. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
  12. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
  13. package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
  14. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
  15. package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
  16. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  17. package/dist/municipal/tickets/tickets.getters.js +0 -128
  18. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  19. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +45 -0
  20. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
  21. package/dist/municipal/tickets/tickets.statistics.aggregation.js +98 -0
  22. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
  23. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
  24. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
  25. package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
  26. package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
  27. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +9 -0
  28. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
  29. package/dist/municipal/tickets/tickets.statistics.getters.js +55 -0
  30. package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
  31. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
  32. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
  33. package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
  34. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
  35. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
  36. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
  37. package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
  38. package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
  39. package/dist/municipal/tickets/tickets.types.d.ts +10 -5
  40. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  41. package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
  42. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
  43. package/dist/talkpilot/calls/calls.constants.js +20 -0
  44. package/dist/talkpilot/calls/calls.constants.js.map +1 -0
  45. package/dist/talkpilot/calls/calls.getters.d.ts +3 -2
  46. package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
  47. package/dist/talkpilot/calls/calls.getters.js +1 -2
  48. package/dist/talkpilot/calls/calls.getters.js.map +1 -1
  49. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +19 -0
  50. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
  51. package/dist/talkpilot/calls/calls.statistics.getters.js +375 -0
  52. package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
  53. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
  54. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
  55. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
  56. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
  57. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
  58. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
  59. package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
  60. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
  61. package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
  62. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
  63. package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
  64. package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
  65. package/dist/talkpilot/calls/calls.types.d.ts +6 -10
  66. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  67. package/dist/talkpilot/calls/calls.types.js +0 -3
  68. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  69. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +36 -0
  70. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -0
  71. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +208 -0
  72. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -0
  73. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +66 -0
  74. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -0
  75. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js +3 -0
  76. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js.map +1 -0
  77. package/dist/talkpilot/calls/index.d.ts +4 -0
  78. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  79. package/dist/talkpilot/calls/index.js +4 -0
  80. package/dist/talkpilot/calls/index.js.map +1 -1
  81. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +2 -1
  82. package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
  83. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +14 -0
  84. package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
  85. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +4 -1
  86. package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
  87. package/dist/talkpilot/clientsConfig/clientsConfig.types.js +6 -0
  88. package/dist/talkpilot/clientsConfig/clientsConfig.types.js.map +1 -1
  89. package/dist/talkpilot/flows/flows.schema.js +1 -1
  90. package/dist/talkpilot/phone_numbers/index.d.ts +2 -2
  91. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +1 -1
  92. package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
  93. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +5 -3
  94. package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
  95. package/dist/talkpilot/phone_numbers/phone_numbers.schema.js +12 -12
  96. package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +4 -4
  97. package/dist/talkpilot/results/results.getter.d.ts.map +1 -1
  98. package/dist/talkpilot/results/results.getter.js.map +1 -1
  99. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.d.ts.map +1 -1
  100. package/dist/talkpilot/retry_analyze/retryAnalyze.getters.js.map +1 -1
  101. package/dist/utils/date.utils.d.ts +49 -0
  102. package/dist/utils/date.utils.d.ts.map +1 -0
  103. package/dist/utils/date.utils.js +103 -0
  104. package/dist/utils/date.utils.js.map +1 -0
  105. package/dist/utils/shared.types.d.ts +5 -0
  106. package/dist/utils/shared.types.d.ts.map +1 -0
  107. package/dist/utils/shared.types.js +3 -0
  108. package/dist/utils/shared.types.js.map +1 -0
  109. package/dist/utils/statistics.aggregation.d.ts +20 -0
  110. package/dist/utils/statistics.aggregation.d.ts.map +1 -0
  111. package/dist/utils/statistics.aggregation.js +43 -0
  112. package/dist/utils/statistics.aggregation.js.map +1 -0
  113. package/package.json +2 -1
  114. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
  115. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +104 -0
  116. package/src/municipal/tickets/index.ts +2 -1
  117. package/src/municipal/tickets/tickets.constants.ts +8 -0
  118. package/src/municipal/tickets/tickets.getters.ts +0 -140
  119. package/src/municipal/tickets/tickets.statistics.aggregation.ts +113 -0
  120. package/src/municipal/tickets/tickets.statistics.getters.ts +93 -0
  121. package/src/municipal/tickets/tickets.types.ts +14 -9
  122. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +46 -0
  123. package/src/talkpilot/calls/__tests__/calls.spec.ts +48 -30
  124. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +281 -0
  125. package/src/talkpilot/calls/calls.constants.ts +20 -0
  126. package/src/talkpilot/calls/calls.getters.ts +6 -6
  127. package/src/talkpilot/calls/calls.statistics.getters.ts +525 -0
  128. package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
  129. package/src/talkpilot/calls/calls.types.ts +16 -15
  130. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +243 -0
  131. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +70 -0
  132. package/src/talkpilot/calls/index.ts +4 -0
  133. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +53 -0
  134. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +19 -9
  135. package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +34 -1
  136. package/src/talkpilot/clientsConfig/clientsConfig.types.ts +9 -1
  137. package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +6 -2
  138. package/src/talkpilot/flows/flows.schema.ts +1 -1
  139. package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +40 -35
  140. package/src/talkpilot/phone_numbers/index.ts +2 -2
  141. package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +10 -6
  142. package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +12 -12
  143. package/src/talkpilot/phone_numbers/phone_numbers.types.ts +4 -4
  144. package/src/talkpilot/results/results.getter.ts +6 -2
  145. package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +13 -4
  146. package/src/utils/date.utils.ts +116 -0
  147. package/src/utils/shared.types.ts +4 -0
@@ -1,5 +1,4 @@
1
1
  import { CityName, getDb, ObjectId, Ticket } from "../index";
2
- import type { SubjectStatsItem } from "./tickets.types";
3
2
  import { Collection, Filter, ObjectId as MongoObjectId } from "mongodb";
4
3
 
5
4
  /**
@@ -68,145 +67,6 @@ export const deleteTicket = async (ticketId: string): Promise<boolean> => {
68
67
  return result.deletedCount > 0;
69
68
  };
70
69
 
71
- /**
72
- * Count tickets by city and date range (createdAt converted to given timezone).
73
- * Used as "open" tickets count when status is not available.
74
- */
75
- export async function getTicketsCountByCityAndDateRange(
76
- cityName: string,
77
- startStr: string,
78
- endStr: string,
79
- timezone: string,
80
- ): Promise<number> {
81
- const doc = await getTicketsCollection()
82
- .aggregate<{ n: number }>([
83
- { $match: { cityName, callSid: { $exists: true, $nin: [null, ""] } } },
84
- {
85
- $addFields: {
86
- dateLocal: {
87
- $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone },
88
- },
89
- },
90
- },
91
- { $match: { dateLocal: { $gte: startStr, $lte: endStr } } },
92
- { $count: "n" },
93
- ])
94
- .next();
95
- return doc?.n ?? 0;
96
- }
97
-
98
- /**
99
- * Count of tickets by department (subject) from departmentsSubjects. Different departments (subject_id) are grouped, and sub-subjects are unified under their department.
100
- * Date filter: createdAt in [startStr, endStr] (in the given timezone). Fallback to "Unclassified" when no match is found in the lookup.
101
- */
102
- export async function getTicketsSubjectStats(
103
- cityName: string,
104
- startStr: string,
105
- endStr: string,
106
- timezone: string,
107
- ): Promise<SubjectStatsItem[]> {
108
- const coll = getTicketsCollection();
109
- const rows = await coll
110
- .aggregate<{ _id: string; subject: string; count: number }>([
111
- { $match: { cityName } },
112
- {
113
- $addFields: {
114
- dateLocal: {
115
- $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone },
116
- },
117
- },
118
- },
119
- { $match: { dateLocal: { $gte: startStr, $lte: endStr } } },
120
- {
121
- $addFields: {
122
- effectiveSubjectId: {
123
- $ifNull: [
124
- "$externalCallFields.event_subject_id",
125
- {
126
- $ifNull: [
127
- "$externalCallFields.event_sub_subject_id",
128
- "$externalCallFields.event_sub_subject_id2",
129
- ],
130
- },
131
- ],
132
- },
133
- },
134
- },
135
- {
136
- $lookup: {
137
- from: "departmentsSubjects",
138
- let: { sid: "$effectiveSubjectId", c: cityName },
139
- pipeline: [
140
- {
141
- $match: {
142
- $expr: {
143
- $and: [
144
- { $eq: ["$cityName", "$$c"] },
145
- {
146
- $or: [
147
- { $eq: ["$subject_id", { $ifNull: ["$$sid", ""] }] },
148
- {
149
- $eq: ["$sub_subject_id", { $ifNull: ["$$sid", ""] }],
150
- },
151
- {
152
- $eq: ["$sub_subject_id2", { $ifNull: ["$$sid", ""] }],
153
- },
154
- ],
155
- },
156
- ],
157
- },
158
- },
159
- },
160
- { $limit: 1 },
161
- ],
162
- as: "subj",
163
- },
164
- },
165
- {
166
- $addFields: {
167
- subject_id: {
168
- $cond: {
169
- if: { $gt: [{ $size: "$subj" }, 0] },
170
- then: {
171
- $ifNull: [{ $arrayElemAt: ["$subj.subject_id", 0] }, ""],
172
- },
173
- else: "",
174
- },
175
- },
176
- subject: {
177
- $cond: {
178
- if: { $gt: [{ $size: "$subj" }, 0] },
179
- then: {
180
- $ifNull: [
181
- { $arrayElemAt: ["$subj.subjectName", 0] },
182
- "Unclassified",
183
- ],
184
- },
185
- else: "Unclassified",
186
- },
187
- },
188
- },
189
- },
190
- {
191
- $group: {
192
- _id: "$subject_id",
193
- subject: { $first: "$subject" },
194
- count: { $sum: 1 },
195
- },
196
- },
197
- { $sort: { count: -1 } },
198
- ])
199
- .toArray();
200
-
201
- const total = rows.reduce((s: number, r: any) => s + r.count, 0);
202
- return rows.map((r: any) => ({
203
- subject_name: r.subject,
204
- subject_id: r._id,
205
- count: r.count,
206
- percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
207
- }));
208
- }
209
-
210
70
  export const findTicketByQuery = async (query: Partial<Ticket>) => {
211
71
  return await getTicketsCollection().findOne(query);
212
72
  };
@@ -0,0 +1,113 @@
1
+ import { CityName } from "../utils/types";
2
+ import { STATS_MAX_TIME_MS, UNCLASSIFIED } from "./tickets.constants";
3
+ import { getTicketsCollection } from "./tickets.getters";
4
+ import type { TicketStatsDateRange } from "./tickets.types";
5
+
6
+ const effectiveSubjectIdExpr = {
7
+ $ifNull: [
8
+ "$externalCallFields.event_subject_id",
9
+ {
10
+ $ifNull: [
11
+ "$externalCallFields.event_sub_subject_id",
12
+ "$externalCallFields.event_sub_subject_id2",
13
+ ],
14
+ },
15
+ ],
16
+ };
17
+
18
+ const departmentsSubjectsLookup = (cityName: CityName) => ({
19
+ $lookup: {
20
+ from: "departmentsSubjects",
21
+ let: { sid: "$effectiveSubjectId", c: cityName },
22
+ pipeline: [
23
+ {
24
+ $match: {
25
+ $expr: {
26
+ $and: [
27
+ { $eq: ["$cityName", "$$c"] },
28
+ {
29
+ $or: [
30
+ { $eq: ["$subject_id", { $ifNull: ["$$sid", ""] }] },
31
+ { $eq: ["$sub_subject_id", { $ifNull: ["$$sid", ""] }] },
32
+ { $eq: ["$sub_subject_id2", { $ifNull: ["$$sid", ""] }] },
33
+ ],
34
+ },
35
+ ],
36
+ },
37
+ },
38
+ },
39
+ { $limit: 1 },
40
+ ],
41
+ as: "subj",
42
+ },
43
+ });
44
+
45
+ const subjectLabelExpr = {
46
+ $cond: {
47
+ if: { $gt: [{ $size: "$subj" }, 0] },
48
+ then: {
49
+ $ifNull: [{ $arrayElemAt: ["$subj.subjectName", 0] }, UNCLASSIFIED],
50
+ },
51
+ else: UNCLASSIFIED,
52
+ },
53
+ };
54
+
55
+ const ticketDateMatch = (dateRange: TicketStatsDateRange) => ({
56
+ createdAt: { $gte: dateRange.from, $lte: dateRange.to },
57
+ });
58
+
59
+ export const isDraftSubjectIdExpr = {
60
+ $or: [
61
+ { $eq: [{ $type: "$externalCallFields.event_subject_id" }, "missing"] },
62
+ { $eq: ["$externalCallFields.event_subject_id", null] },
63
+ {
64
+ $regexMatch: {
65
+ input: { $ifNull: ["$externalCallFields.event_subject_id", ""] },
66
+ regex: /^\s*$/,
67
+ },
68
+ },
69
+ ],
70
+ };
71
+
72
+ export const ticketsWithCallSidMatch = (
73
+ cityName: CityName,
74
+ dateRange: TicketStatsDateRange,
75
+ ) => ({
76
+ cityName,
77
+ callSid: { $exists: true, $nin: [null, ""] },
78
+ ...ticketDateMatch(dateRange),
79
+ });
80
+
81
+ export const ticketsByCallSidsMatch = (
82
+ cityName: CityName,
83
+ callSids: string[],
84
+ dateRange?: TicketStatsDateRange,
85
+ ) => ({
86
+ cityName,
87
+ callSid: { $in: callSids },
88
+ ...(dateRange ? ticketDateMatch(dateRange) : {}),
89
+ });
90
+
91
+ export const subjectGroupStages = (
92
+ cityName: CityName,
93
+ limit?: number,
94
+ ): Record<string, unknown>[] => {
95
+ const shouldLimit = typeof limit === "number" && limit > 0;
96
+ return [
97
+ { $addFields: { effectiveSubjectId: effectiveSubjectIdExpr } },
98
+ departmentsSubjectsLookup(cityName),
99
+ { $addFields: { subject: subjectLabelExpr } },
100
+ { $group: { _id: "$subject", count: { $sum: 1 } } },
101
+ { $sort: { count: -1 } },
102
+ ...(shouldLimit ? [{ $limit: limit }] : []),
103
+ ];
104
+ };
105
+
106
+ export const runTicketStatsAggregate = async <T>(
107
+ pipeline: Record<string, unknown>[],
108
+ ): Promise<T[]> => {
109
+ const rows = await getTicketsCollection()
110
+ .aggregate(pipeline, { maxTimeMS: STATS_MAX_TIME_MS })
111
+ .toArray();
112
+ return rows as T[];
113
+ };
@@ -0,0 +1,93 @@
1
+ import { CityName } from "../utils/types";
2
+ import {
3
+ DAY_MS,
4
+ endOfCalendarDayInTz,
5
+ startOfCalendarDayInTz,
6
+ } from "../../utils/date.utils";
7
+ import { DEFAULT_LOOKBACK_DAYS } from "./tickets.constants";
8
+ import {
9
+ isDraftSubjectIdExpr,
10
+ runTicketStatsAggregate,
11
+ subjectGroupStages,
12
+ ticketsByCallSidsMatch,
13
+ ticketsWithCallSidMatch,
14
+ } from "./tickets.statistics.aggregation";
15
+ import type {
16
+ SubjectItem,
17
+ TicketStatsDateRange,
18
+ TicketStatsDateScope,
19
+ } from "./tickets.types";
20
+
21
+ export const resolveTicketStatsDateRange = (
22
+ dateScope?: TicketStatsDateScope,
23
+ ): TicketStatsDateRange => {
24
+ const to = dateScope?.to ?? new Date();
25
+ const from =
26
+ dateScope?.from ?? new Date(to.getTime() - DEFAULT_LOOKBACK_DAYS * DAY_MS);
27
+ return { from, to };
28
+ };
29
+
30
+ export const ticketStatsDateScopeFromYmd = (
31
+ startStr: string,
32
+ endStr: string,
33
+ timezone: string,
34
+ ): TicketStatsDateScope => ({
35
+ from: startOfCalendarDayInTz(startStr, timezone),
36
+ to: endOfCalendarDayInTz(endStr, timezone),
37
+ });
38
+
39
+ export const findCallSidsWithTicketsByCity = async (
40
+ cityName: CityName,
41
+ dateScope?: TicketStatsDateScope,
42
+ ): Promise<string[]> => {
43
+ const rows = await runTicketStatsAggregate<{ _id: unknown }>([
44
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
45
+ { $group: { _id: "$callSid" } },
46
+ ]);
47
+ return rows.map((r) => String(r._id)).filter(Boolean);
48
+ };
49
+
50
+ export const findCallSidsWithDraftTicketsByCity = async (
51
+ cityName: CityName,
52
+ dateScope?: TicketStatsDateScope,
53
+ ): Promise<string[]> => {
54
+ const rows = await runTicketStatsAggregate<{ _id: unknown }>([
55
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
56
+ { $match: { $expr: isDraftSubjectIdExpr } },
57
+ { $group: { _id: "$callSid" } },
58
+ ]);
59
+ return rows.map((r) => String(r._id)).filter(Boolean);
60
+ };
61
+
62
+ export const findSubjectsByCityAndDateRange = async (
63
+ cityName: CityName,
64
+ dateScope?: TicketStatsDateScope,
65
+ limit?: number,
66
+ ): Promise<SubjectItem[]> => {
67
+ const rows = await runTicketStatsAggregate<{ _id: unknown; count: number }>([
68
+ { $match: ticketsWithCallSidMatch(cityName, resolveTicketStatsDateRange(dateScope)) },
69
+ ...subjectGroupStages(cityName, limit),
70
+ ]);
71
+ return rows.map((r) => ({ subject: String(r._id), count: r.count }));
72
+ };
73
+
74
+ export const findSubjectsByCallSids = async (
75
+ cityName: CityName,
76
+ callSids: string[],
77
+ limit?: number,
78
+ dateScope?: TicketStatsDateScope,
79
+ ): Promise<SubjectItem[]> => {
80
+ if (!callSids.length) return [];
81
+
82
+ const rows = await runTicketStatsAggregate<{ _id: unknown; count: number }>([
83
+ {
84
+ $match: ticketsByCallSidsMatch(
85
+ cityName,
86
+ callSids,
87
+ dateScope ? resolveTicketStatsDateRange(dateScope) : undefined,
88
+ ),
89
+ },
90
+ ...subjectGroupStages(cityName, limit),
91
+ ]);
92
+ return rows.map((r) => ({ subject: String(r._id), count: r.count }));
93
+ };
@@ -9,7 +9,6 @@ export type Ticket = {
9
9
  callSid?: string;
10
10
  cityName: CityName;
11
11
  externalCallFields: {
12
- // Request fields
13
12
  first_name?: string;
14
13
  last_name?: string;
15
14
  event_description?: string;
@@ -18,10 +17,9 @@ export type Ticket = {
18
17
  event_subject_id?: string;
19
18
  event_sub_subject_id?: string;
20
19
  event_sub_subject_id2?: string | null;
21
- // Response fields (only if external call was successful)
22
20
  call_number?: string;
23
- status?: number; // Response status from Bina API (1 = success)
24
- error?: string; // Error message from Bina API if any
21
+ status?: number;
22
+ error?: string;
25
23
  image1?: string;
26
24
  image2?: string;
27
25
  image3?: string;
@@ -34,10 +32,17 @@ export type Ticket = {
34
32
 
35
33
  export type TicketDoc = WithId<Ticket>;
36
34
 
37
- /** Call count by department (subject). */
38
- export type SubjectStatsItem = {
39
- subject_name: string;
40
- subject_id: string;
35
+ export type SubjectItem = {
36
+ subject: string;
41
37
  count: number;
42
- percentage: number;
38
+ };
39
+
40
+ export type TicketStatsDateScope = {
41
+ from?: Date;
42
+ to?: Date;
43
+ };
44
+
45
+ export type TicketStatsDateRange = {
46
+ from: Date;
47
+ to: Date;
43
48
  };
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2
+ import { getDashboardStats } from "../dashboard/calls.dashboard";
3
+ import { getCallsCollection } from "../calls.getters";
4
+ import { getClientsConfigCollection } from "../../clientsConfig/clientsConfig.getters";
5
+
6
+ describe("getDashboardStats", () => {
7
+ beforeEach(async () => {
8
+ await getCallsCollection().deleteMany({});
9
+ await getClientsConfigCollection().deleteMany({});
10
+ });
11
+
12
+ afterEach(async () => {
13
+ await getCallsCollection().deleteMany({});
14
+ await getClientsConfigCollection().deleteMany({});
15
+ });
16
+
17
+ it("should return correct empty dashboard stats when no calls exist", async () => {
18
+ const clientId = "client-dash-123";
19
+
20
+ await getClientsConfigCollection().insertOne({
21
+ clientId,
22
+ timezone: "UTC",
23
+ products: {},
24
+ } as any);
25
+
26
+ const params = {
27
+ clientId,
28
+ startDate: "2026-05-01",
29
+ endDate: "2026-05-31",
30
+ };
31
+
32
+ const result = await getDashboardStats(params);
33
+
34
+ expect(result).toBeDefined();
35
+ expect(result.kpis.totalCalls).toBe(0);
36
+ expect(result.kpis.completedCount).toBe(0);
37
+ expect(result.kpis.avgDurationSeconds).toBe(0);
38
+ expect(result.charts.volumeData).toEqual([]);
39
+ expect(result.charts.heatmap).toEqual({});
40
+ expect(result.charts.callLengthBuckets).toEqual({
41
+ short: 0,
42
+ medium: 0,
43
+ long: 0,
44
+ });
45
+ });
46
+ });
@@ -10,7 +10,7 @@ import {
10
10
  getCallsCollection,
11
11
  pushToolExecution,
12
12
  } from "../calls.getters";
13
- import type { ToolExecution } from '../calls.types';
13
+ import type { ToolExecution } from "../calls.types";
14
14
  import { ObjectId } from "mongodb";
15
15
  import { createOutGoingCallDoc } from "../../../test-utils/factories";
16
16
 
@@ -115,7 +115,7 @@ describe("db.calls", () => {
115
115
  });
116
116
  });
117
117
 
118
- describe('pushToolExecution()', () => {
118
+ describe("pushToolExecution()", () => {
119
119
  let callSid: string;
120
120
 
121
121
  beforeEach(async () => {
@@ -124,63 +124,73 @@ describe("db.calls", () => {
124
124
  callSid = call.callSid;
125
125
  });
126
126
 
127
- const makeHttpExecution = (overrides?: Partial<ToolExecution>): ToolExecution => ({
128
- toolName: 'sendSms',
127
+ const makeHttpExecution = (
128
+ overrides?: Partial<ToolExecution>,
129
+ ): ToolExecution => ({
130
+ toolName: "sendSms",
129
131
  executedAt: new Date(),
130
132
  durationMs: 120,
131
- args: { to: '+1234567890', message: 'hello' },
132
- meta: { kind: 'http', url: 'https://api.example.com/sms', method: 'POST' },
133
- status: 'success',
133
+ args: { to: "+1234567890", message: "hello" },
134
+ meta: {
135
+ kind: "http",
136
+ url: "https://api.example.com/sms",
137
+ method: "POST",
138
+ },
139
+ status: "success",
134
140
  httpStatus: 200,
135
141
  ...overrides,
136
142
  });
137
143
 
138
- it('should append an execution to a call with none', async () => {
144
+ it("should append an execution to a call with none", async () => {
139
145
  const execution = makeHttpExecution();
140
146
  await pushToolExecution(callSid, execution);
141
147
 
142
148
  const result = await getCallByCallSid(callSid);
143
149
  expect(result?.toolExecutions).toHaveLength(1);
144
150
  expect(result?.toolExecutions?.[0]).toMatchObject({
145
- toolName: 'sendSms',
146
- status: 'success',
151
+ toolName: "sendSms",
152
+ status: "success",
147
153
  httpStatus: 200,
148
154
  });
149
155
  });
150
156
 
151
- it('should maintain insertion order across multiple pushes', async () => {
152
- const first = makeHttpExecution({ toolName: 'first' });
153
- const second = makeHttpExecution({ toolName: 'second' });
154
- const third = makeHttpExecution({ toolName: 'third' });
157
+ it("should maintain insertion order across multiple pushes", async () => {
158
+ const first = makeHttpExecution({ toolName: "first" });
159
+ const second = makeHttpExecution({ toolName: "second" });
160
+ const third = makeHttpExecution({ toolName: "third" });
155
161
 
156
162
  await pushToolExecution(callSid, first);
157
163
  await pushToolExecution(callSid, second);
158
164
  await pushToolExecution(callSid, third);
159
165
 
160
166
  const result = await getCallByCallSid(callSid);
161
- expect(result?.toolExecutions?.map(e => e.toolName)).toEqual(['first', 'second', 'third']);
167
+ expect(result?.toolExecutions?.map((e) => e.toolName)).toEqual([
168
+ "first",
169
+ "second",
170
+ "third",
171
+ ]);
162
172
  });
163
173
 
164
- it('should store an internal tool execution', async () => {
174
+ it("should store an internal tool execution", async () => {
165
175
  const execution: ToolExecution = {
166
- toolName: 'endFlow',
176
+ toolName: "endFlow",
167
177
  executedAt: new Date(),
168
178
  durationMs: 5,
169
179
  args: {},
170
- meta: { kind: 'internal' },
171
- status: 'success',
180
+ meta: { kind: "internal" },
181
+ status: "success",
172
182
  };
173
183
 
174
184
  await pushToolExecution(callSid, execution);
175
185
 
176
186
  const result = await getCallByCallSid(callSid);
177
187
  expect(result?.toolExecutions?.[0]).toMatchObject({
178
- toolName: 'endFlow',
179
- meta: { kind: 'internal' },
188
+ toolName: "endFlow",
189
+ meta: { kind: "internal" },
180
190
  });
181
191
  });
182
192
 
183
- it('should store redacted args for sensitive tools', async () => {
193
+ it("should store redacted args for sensitive tools", async () => {
184
194
  const execution = makeHttpExecution({ args: { _redacted: true } });
185
195
  await pushToolExecution(callSid, execution);
186
196
 
@@ -188,26 +198,34 @@ describe("db.calls", () => {
188
198
  expect(result?.toolExecutions?.[0].args).toEqual({ _redacted: true });
189
199
  });
190
200
 
191
- it('should store an error execution', async () => {
192
- const execution = makeHttpExecution({ status: 'error', httpStatus: 500 });
201
+ it("should store an error execution", async () => {
202
+ const execution = makeHttpExecution({ status: "error", httpStatus: 500 });
193
203
  await pushToolExecution(callSid, execution);
194
204
 
195
205
  const result = await getCallByCallSid(callSid);
196
- expect(result?.toolExecutions?.[0]).toMatchObject({ status: 'error', httpStatus: 500 });
206
+ expect(result?.toolExecutions?.[0]).toMatchObject({
207
+ status: "error",
208
+ httpStatus: 500,
209
+ });
197
210
  });
198
211
 
199
- it('should be a no-op for an unknown callSid', async () => {
200
- await expect(pushToolExecution('nonexistent-sid', makeHttpExecution())).resolves.not.toThrow();
212
+ it("should be a no-op for an unknown callSid", async () => {
213
+ await expect(
214
+ pushToolExecution("nonexistent-sid", makeHttpExecution()),
215
+ ).resolves.not.toThrow();
201
216
  });
202
217
 
203
- it('should store the response body', async () => {
218
+ it("should store the response body", async () => {
204
219
  const execution = makeHttpExecution({
205
- response: { userId: '123', status: 'sent' },
220
+ response: { userId: "123", status: "sent" },
206
221
  });
207
222
  await pushToolExecution(callSid, execution);
208
223
 
209
224
  const result = await getCallByCallSid(callSid);
210
- expect(result?.toolExecutions?.[0].response).toEqual({ userId: '123', status: 'sent' });
225
+ expect(result?.toolExecutions?.[0].response).toEqual({
226
+ userId: "123",
227
+ status: "sent",
228
+ });
211
229
  });
212
230
  });
213
231