@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.
- package/README.md +117 -108
- package/README_OLD.md +160 -0
- package/dist/municipal/tickets/index.d.ts +2 -1
- package/dist/municipal/tickets/index.d.ts.map +1 -1
- package/dist/municipal/tickets/index.js +1 -0
- package/dist/municipal/tickets/index.js.map +1 -1
- package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
- package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.constants.js +10 -0
- package/dist/municipal/tickets/tickets.constants.js.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
- package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.getters.js +0 -128
- package/dist/municipal/tickets/tickets.getters.js.map +1 -1
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +45 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js +98 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts +9 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js +55 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
- package/dist/municipal/tickets/tickets.types.d.ts +10 -5
- package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
- package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.constants.js +20 -0
- package/dist/talkpilot/calls/calls.constants.js.map +1 -0
- package/dist/talkpilot/calls/calls.getters.d.ts +3 -2
- package/dist/talkpilot/calls/calls.getters.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.getters.js +1 -2
- package/dist/talkpilot/calls/calls.getters.js.map +1 -1
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts +19 -0
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js +375 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
- package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
- package/dist/talkpilot/calls/calls.types.d.ts +6 -10
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.types.js +0 -3
- package/dist/talkpilot/calls/calls.types.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +36 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js +208 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +66 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js +3 -0
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.js.map +1 -0
- package/dist/talkpilot/calls/index.d.ts +4 -0
- package/dist/talkpilot/calls/index.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.js +4 -0
- package/dist/talkpilot/calls/index.js.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts +2 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.d.ts.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.js +14 -0
- package/dist/talkpilot/clientsConfig/clientsConfig.getters.js.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts +4 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.d.ts.map +1 -1
- package/dist/talkpilot/clientsConfig/clientsConfig.types.js +6 -0
- package/dist/talkpilot/clientsConfig/clientsConfig.types.js.map +1 -1
- package/dist/talkpilot/flows/flows.schema.js +1 -1
- package/dist/talkpilot/phone_numbers/index.d.ts +2 -2
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.d.ts.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js +5 -3
- package/dist/talkpilot/phone_numbers/phone_numbers.getter.js.map +1 -1
- package/dist/talkpilot/phone_numbers/phone_numbers.schema.js +12 -12
- package/dist/talkpilot/phone_numbers/phone_numbers.types.d.ts +4 -4
- package/dist/talkpilot/results/results.getter.d.ts.map +1 -1
- package/dist/talkpilot/results/results.getter.js.map +1 -1
- package/dist/talkpilot/retry_analyze/retryAnalyze.getters.d.ts.map +1 -1
- package/dist/talkpilot/retry_analyze/retryAnalyze.getters.js.map +1 -1
- package/dist/utils/date.utils.d.ts +49 -0
- package/dist/utils/date.utils.d.ts.map +1 -0
- package/dist/utils/date.utils.js +103 -0
- package/dist/utils/date.utils.js.map +1 -0
- package/dist/utils/shared.types.d.ts +5 -0
- package/dist/utils/shared.types.d.ts.map +1 -0
- package/dist/utils/shared.types.js +3 -0
- package/dist/utils/shared.types.js.map +1 -0
- package/dist/utils/statistics.aggregation.d.ts +20 -0
- package/dist/utils/statistics.aggregation.d.ts.map +1 -0
- package/dist/utils/statistics.aggregation.js +43 -0
- package/dist/utils/statistics.aggregation.js.map +1 -0
- package/package.json +2 -1
- package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
- package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +104 -0
- package/src/municipal/tickets/index.ts +2 -1
- package/src/municipal/tickets/tickets.constants.ts +8 -0
- package/src/municipal/tickets/tickets.getters.ts +0 -140
- package/src/municipal/tickets/tickets.statistics.aggregation.ts +113 -0
- package/src/municipal/tickets/tickets.statistics.getters.ts +93 -0
- package/src/municipal/tickets/tickets.types.ts +14 -9
- package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +46 -0
- package/src/talkpilot/calls/__tests__/calls.spec.ts +48 -30
- package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +281 -0
- package/src/talkpilot/calls/calls.constants.ts +20 -0
- package/src/talkpilot/calls/calls.getters.ts +6 -6
- package/src/talkpilot/calls/calls.statistics.getters.ts +525 -0
- package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
- package/src/talkpilot/calls/calls.types.ts +16 -15
- package/src/talkpilot/calls/dashboard/calls.dashboard.ts +243 -0
- package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +70 -0
- package/src/talkpilot/calls/index.ts +4 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.getters.spec.ts +53 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +19 -9
- package/src/talkpilot/clientsConfig/clientsConfig.getters.ts +34 -1
- package/src/talkpilot/clientsConfig/clientsConfig.types.ts +9 -1
- package/src/talkpilot/flows/__tests__/flows.schema.spec.ts +6 -2
- package/src/talkpilot/flows/flows.schema.ts +1 -1
- package/src/talkpilot/phone_numbers/__tests__/phone_numbers.spec.ts +40 -35
- package/src/talkpilot/phone_numbers/index.ts +2 -2
- package/src/talkpilot/phone_numbers/phone_numbers.getter.ts +10 -6
- package/src/talkpilot/phone_numbers/phone_numbers.schema.ts +12 -12
- package/src/talkpilot/phone_numbers/phone_numbers.types.ts +4 -4
- package/src/talkpilot/results/results.getter.ts +6 -2
- package/src/talkpilot/retry_analyze/retryAnalyze.getters.ts +13 -4
- package/src/utils/date.utils.ts +116 -0
- 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;
|
|
24
|
-
error?: string;
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
subject_name: string;
|
|
40
|
-
subject_id: string;
|
|
35
|
+
export type SubjectItem = {
|
|
36
|
+
subject: string;
|
|
41
37
|
count: number;
|
|
42
|
-
|
|
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
|
|
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(
|
|
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 = (
|
|
128
|
-
|
|
127
|
+
const makeHttpExecution = (
|
|
128
|
+
overrides?: Partial<ToolExecution>,
|
|
129
|
+
): ToolExecution => ({
|
|
130
|
+
toolName: "sendSms",
|
|
129
131
|
executedAt: new Date(),
|
|
130
132
|
durationMs: 120,
|
|
131
|
-
args: { to:
|
|
132
|
-
meta: {
|
|
133
|
-
|
|
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(
|
|
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:
|
|
146
|
-
status:
|
|
151
|
+
toolName: "sendSms",
|
|
152
|
+
status: "success",
|
|
147
153
|
httpStatus: 200,
|
|
148
154
|
});
|
|
149
155
|
});
|
|
150
156
|
|
|
151
|
-
it(
|
|
152
|
-
const first = makeHttpExecution({ toolName:
|
|
153
|
-
const second = makeHttpExecution({ toolName:
|
|
154
|
-
const third = makeHttpExecution({ toolName:
|
|
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([
|
|
167
|
+
expect(result?.toolExecutions?.map((e) => e.toolName)).toEqual([
|
|
168
|
+
"first",
|
|
169
|
+
"second",
|
|
170
|
+
"third",
|
|
171
|
+
]);
|
|
162
172
|
});
|
|
163
173
|
|
|
164
|
-
it(
|
|
174
|
+
it("should store an internal tool execution", async () => {
|
|
165
175
|
const execution: ToolExecution = {
|
|
166
|
-
toolName:
|
|
176
|
+
toolName: "endFlow",
|
|
167
177
|
executedAt: new Date(),
|
|
168
178
|
durationMs: 5,
|
|
169
179
|
args: {},
|
|
170
|
-
meta: { kind:
|
|
171
|
-
status:
|
|
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:
|
|
179
|
-
meta: { kind:
|
|
188
|
+
toolName: "endFlow",
|
|
189
|
+
meta: { kind: "internal" },
|
|
180
190
|
});
|
|
181
191
|
});
|
|
182
192
|
|
|
183
|
-
it(
|
|
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(
|
|
192
|
-
const execution = makeHttpExecution({ status:
|
|
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({
|
|
206
|
+
expect(result?.toolExecutions?.[0]).toMatchObject({
|
|
207
|
+
status: "error",
|
|
208
|
+
httpStatus: 500,
|
|
209
|
+
});
|
|
197
210
|
});
|
|
198
211
|
|
|
199
|
-
it(
|
|
200
|
-
await expect(
|
|
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(
|
|
218
|
+
it("should store the response body", async () => {
|
|
204
219
|
const execution = makeHttpExecution({
|
|
205
|
-
response: { userId:
|
|
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({
|
|
225
|
+
expect(result?.toolExecutions?.[0].response).toEqual({
|
|
226
|
+
userId: "123",
|
|
227
|
+
status: "sent",
|
|
228
|
+
});
|
|
211
229
|
});
|
|
212
230
|
});
|
|
213
231
|
|