@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import { getCallsCollection } from "../calls.getters";
|
|
3
|
+
import { getClientConfig } from "../../clientsConfig";
|
|
4
|
+
import utc from "dayjs/plugin/utc";
|
|
5
|
+
import timezonePlugin from "dayjs/plugin/timezone";
|
|
6
|
+
import {
|
|
7
|
+
DashboardAggregationResult,
|
|
8
|
+
DashboardDailyTrendMetric,
|
|
9
|
+
DashboardHeatmapMetric,
|
|
10
|
+
DashboardReportQuery,
|
|
11
|
+
DashboardReportResponse,
|
|
12
|
+
DashboardSummaryMetrics,
|
|
13
|
+
RawDailyAggregationResult,
|
|
14
|
+
RawHourlyAggregationResult,
|
|
15
|
+
} from "./calls.dashboard.types";
|
|
16
|
+
import { CallLengthThresholds } from "src/utils/shared.types";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_KPI_DATA = {
|
|
19
|
+
totalCalls: 0,
|
|
20
|
+
totalDuration: 0,
|
|
21
|
+
completedCount: 0,
|
|
22
|
+
failedCount: 0,
|
|
23
|
+
noAnswerCount: 0,
|
|
24
|
+
busyCount: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
dayjs.extend(utc);
|
|
28
|
+
dayjs.extend(timezonePlugin);
|
|
29
|
+
|
|
30
|
+
function buildKpisPipeline() {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
$group: {
|
|
34
|
+
_id: null,
|
|
35
|
+
totalCalls: { $sum: 1 },
|
|
36
|
+
totalDuration: { $sum: "$callLength" },
|
|
37
|
+
completedCount: {
|
|
38
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
39
|
+
},
|
|
40
|
+
failedCount: {
|
|
41
|
+
$sum: { $cond: [{ $eq: ["$status", "failed"] }, 1, 0] },
|
|
42
|
+
},
|
|
43
|
+
noAnswerCount: {
|
|
44
|
+
$sum: { $cond: [{ $eq: ["$status", "no-answer"] }, 1, 0] },
|
|
45
|
+
},
|
|
46
|
+
busyCount: {
|
|
47
|
+
$sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildDailyDataPipeline(timezone: string) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
$group: {
|
|
58
|
+
_id: {
|
|
59
|
+
$dateToString: {
|
|
60
|
+
format: "%Y-%m-%d",
|
|
61
|
+
date: "$createdAt",
|
|
62
|
+
timezone: timezone,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
count: { $sum: 1 },
|
|
66
|
+
completed: {
|
|
67
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ $sort: { _id: 1 } },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildHourlyDataPipeline(timezone: string) {
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
$group: {
|
|
79
|
+
_id: {
|
|
80
|
+
day: {
|
|
81
|
+
$dateToString: {
|
|
82
|
+
format: "%Y-%m-%d",
|
|
83
|
+
date: "$createdAt",
|
|
84
|
+
timezone: timezone,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
hour: { $hour: { date: "$createdAt", timezone: timezone } },
|
|
88
|
+
},
|
|
89
|
+
count: { $sum: 1 },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildCallLengthBucketsPipeline(
|
|
96
|
+
thresholds: CallLengthThresholds,
|
|
97
|
+
) {
|
|
98
|
+
const { shortThreshold, mediumThreshold } = thresholds;
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
$group: {
|
|
103
|
+
_id: null,
|
|
104
|
+
short: {
|
|
105
|
+
$sum: { $cond: [{ $lt: ["$callLength", shortThreshold] }, 1, 0] },
|
|
106
|
+
},
|
|
107
|
+
medium: {
|
|
108
|
+
$sum: {
|
|
109
|
+
$cond: [
|
|
110
|
+
{
|
|
111
|
+
$and: [
|
|
112
|
+
{ $gte: ["$callLength", shortThreshold] },
|
|
113
|
+
{ $lte: ["$callLength", mediumThreshold] },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
1,
|
|
117
|
+
0,
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
long: {
|
|
122
|
+
$sum: { $cond: [{ $gt: ["$callLength", mediumThreshold] }, 1, 0] },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildHeatmapData(
|
|
130
|
+
hourlyDataRaw: RawHourlyAggregationResult[],
|
|
131
|
+
): Record<string, DashboardHeatmapMetric[]> {
|
|
132
|
+
const heatmapMap = new Map<string, Map<number, number>>();
|
|
133
|
+
|
|
134
|
+
for (const bucket of hourlyDataRaw) {
|
|
135
|
+
const dayKey = bucket._id.day;
|
|
136
|
+
const hour = bucket._id.hour;
|
|
137
|
+
if (!heatmapMap.has(dayKey)) heatmapMap.set(dayKey, new Map());
|
|
138
|
+
heatmapMap.get(dayKey)!.set(hour, bucket.count);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toSortedBuckets = (
|
|
142
|
+
map: Map<number, number>,
|
|
143
|
+
): DashboardHeatmapMetric[] =>
|
|
144
|
+
Array.from(map.entries())
|
|
145
|
+
.sort(([a], [b]) => a - b)
|
|
146
|
+
.map(([h, c]) => ({
|
|
147
|
+
hour: h,
|
|
148
|
+
calls: c,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const heatmap: Record<string, DashboardHeatmapMetric[]> = {};
|
|
152
|
+
for (const [day, hourMap] of Array.from(heatmapMap.entries()).sort()) {
|
|
153
|
+
heatmap[day] = toSortedBuckets(hourMap);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return heatmap;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function getDashboardStats(
|
|
160
|
+
params: DashboardReportQuery,
|
|
161
|
+
): Promise<DashboardReportResponse> {
|
|
162
|
+
const { clientId, startDate, endDate } = params;
|
|
163
|
+
const clientConfig = await getClientConfig(clientId);
|
|
164
|
+
const timezone = clientConfig?.timezone ?? "UTC";
|
|
165
|
+
|
|
166
|
+
const startDateObj = dayjs.tz(startDate, timezone).startOf("day").toDate();
|
|
167
|
+
const endDateObj = dayjs.tz(endDate, timezone).endOf("day").toDate();
|
|
168
|
+
|
|
169
|
+
const thresholds: CallLengthThresholds = {
|
|
170
|
+
shortThreshold: clientConfig?.callLengthThresholds?.shortThreshold ?? 60,
|
|
171
|
+
mediumThreshold: clientConfig?.callLengthThresholds?.mediumThreshold ?? 180,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const pipeline = [
|
|
175
|
+
{
|
|
176
|
+
$match: {
|
|
177
|
+
clientId,
|
|
178
|
+
createdAt: { $gte: startDateObj, $lte: endDateObj },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
$facet: {
|
|
183
|
+
kpis: buildKpisPipeline(),
|
|
184
|
+
dailyData: buildDailyDataPipeline(timezone),
|
|
185
|
+
hourlyData: buildHourlyDataPipeline(timezone),
|
|
186
|
+
callLengthBuckets: buildCallLengthBucketsPipeline(thresholds),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const callsCollection = getCallsCollection();
|
|
192
|
+
const [aggregatedResult] = await callsCollection
|
|
193
|
+
.aggregate<DashboardAggregationResult>(pipeline)
|
|
194
|
+
.toArray();
|
|
195
|
+
|
|
196
|
+
const kpiData = aggregatedResult?.kpis?.[0] ?? DEFAULT_KPI_DATA;
|
|
197
|
+
const dailyDataRaw = aggregatedResult?.dailyData ?? [];
|
|
198
|
+
const hourlyDataRaw = aggregatedResult?.hourlyData ?? [];
|
|
199
|
+
const callLengthRaw = aggregatedResult?.callLengthBuckets?.[0] ?? {
|
|
200
|
+
short: 0,
|
|
201
|
+
medium: 0,
|
|
202
|
+
long: 0,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const kpis: DashboardSummaryMetrics = {
|
|
206
|
+
totalCalls: kpiData.totalCalls,
|
|
207
|
+
avgDurationSeconds:
|
|
208
|
+
kpiData.totalCalls > 0
|
|
209
|
+
? Math.round((kpiData.totalDuration ?? 0) / kpiData.totalCalls)
|
|
210
|
+
: 0,
|
|
211
|
+
timeSavedMinutes: Math.round((kpiData.totalDuration ?? 0) / 60),
|
|
212
|
+
successRate:
|
|
213
|
+
kpiData.totalCalls > 0
|
|
214
|
+
? Math.round((kpiData.completedCount / kpiData.totalCalls) * 1000) / 10
|
|
215
|
+
: 0,
|
|
216
|
+
completedCount: kpiData.completedCount,
|
|
217
|
+
failedCount: kpiData.failedCount,
|
|
218
|
+
noAnswerCount: kpiData.noAnswerCount,
|
|
219
|
+
busyCount: kpiData.busyCount,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const volumeData: DashboardDailyTrendMetric[] = dailyDataRaw.map((d) => ({
|
|
223
|
+
date: d._id,
|
|
224
|
+
count: d.count,
|
|
225
|
+
completed: d.completed,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const heatmap = buildHeatmapData(hourlyDataRaw);
|
|
229
|
+
|
|
230
|
+
const response: DashboardReportResponse = {
|
|
231
|
+
kpis,
|
|
232
|
+
charts: {
|
|
233
|
+
volumeData,
|
|
234
|
+
heatmap,
|
|
235
|
+
callLengthBuckets: {
|
|
236
|
+
short: callLengthRaw.short,
|
|
237
|
+
medium: callLengthRaw.medium,
|
|
238
|
+
long: callLengthRaw.long,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
return response;
|
|
243
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type DashboardHeatmapMetric = {
|
|
2
|
+
hour: number;
|
|
3
|
+
calls: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type DashboardDailyTrendMetric = {
|
|
7
|
+
date: string;
|
|
8
|
+
count: number;
|
|
9
|
+
completed: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type DashboardDurationSegments = {
|
|
13
|
+
short: number;
|
|
14
|
+
medium: number;
|
|
15
|
+
long: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DashboardSummaryMetrics = {
|
|
19
|
+
totalCalls: number;
|
|
20
|
+
avgDurationSeconds: number;
|
|
21
|
+
timeSavedMinutes: number;
|
|
22
|
+
successRate: number;
|
|
23
|
+
completedCount: number;
|
|
24
|
+
failedCount: number;
|
|
25
|
+
noAnswerCount: number;
|
|
26
|
+
busyCount: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type DashboardVisualData = {
|
|
30
|
+
volumeData: DashboardDailyTrendMetric[];
|
|
31
|
+
heatmap: Record<string, DashboardHeatmapMetric[]>;
|
|
32
|
+
callLengthBuckets: DashboardDurationSegments;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type DashboardReportQuery = {
|
|
36
|
+
clientId: string;
|
|
37
|
+
startDate: string;
|
|
38
|
+
endDate: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DashboardReportResponse = {
|
|
42
|
+
kpis: DashboardSummaryMetrics;
|
|
43
|
+
charts: DashboardVisualData;
|
|
44
|
+
};
|
|
45
|
+
type RawKpiResult = {
|
|
46
|
+
totalCalls: number;
|
|
47
|
+
totalDuration: number | null;
|
|
48
|
+
completedCount: number;
|
|
49
|
+
failedCount: number;
|
|
50
|
+
noAnswerCount: number;
|
|
51
|
+
busyCount: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type RawDailyAggregationResult = {
|
|
55
|
+
_id: string;
|
|
56
|
+
count: number;
|
|
57
|
+
completed: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type RawHourlyAggregationResult = {
|
|
61
|
+
_id: { day: string; hour: number };
|
|
62
|
+
count: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface DashboardAggregationResult {
|
|
66
|
+
kpis: RawKpiResult[];
|
|
67
|
+
dailyData: RawDailyAggregationResult[];
|
|
68
|
+
hourlyData: RawHourlyAggregationResult[];
|
|
69
|
+
callLengthBuckets: DashboardDurationSegments[];
|
|
70
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
|
2
|
+
import {
|
|
3
|
+
getClientsConfigCollection,
|
|
4
|
+
updateProductConfig,
|
|
5
|
+
} from "../clientsConfig.getters";
|
|
6
|
+
|
|
7
|
+
describe("updateProductConfig", () => {
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
await getClientsConfigCollection().deleteMany({});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await getClientsConfigCollection().deleteMany({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should update partial fields using correct dot notation", async () => {
|
|
17
|
+
const clientId = "client-123";
|
|
18
|
+
const collection = getClientsConfigCollection();
|
|
19
|
+
|
|
20
|
+
await collection.insertOne({
|
|
21
|
+
clientId,
|
|
22
|
+
products: {
|
|
23
|
+
websiteTalk: {
|
|
24
|
+
language: "English",
|
|
25
|
+
companyName: "OldCorp",
|
|
26
|
+
defaultBaseUrl: "https://old-url.com",
|
|
27
|
+
logoUrl: "",
|
|
28
|
+
timezone: "UTC",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
} as any);
|
|
32
|
+
|
|
33
|
+
const updates = { language: "Hebrew", companyName: "TestCorp" };
|
|
34
|
+
await updateProductConfig(clientId, "websiteTalk", updates);
|
|
35
|
+
|
|
36
|
+
const updatedDoc = await collection.findOne({ clientId });
|
|
37
|
+
|
|
38
|
+
expect(updatedDoc).toBeDefined();
|
|
39
|
+
expect(updatedDoc?.products.websiteTalk?.language).toBe("Hebrew");
|
|
40
|
+
expect(updatedDoc?.products.websiteTalk?.companyName).toBe("TestCorp");
|
|
41
|
+
expect(updatedDoc?.products.websiteTalk?.defaultBaseUrl).toBe(
|
|
42
|
+
"https://old-url.com",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should throw an error if no client config is found", async () => {
|
|
47
|
+
await expect(
|
|
48
|
+
updateProductConfig("invalid-client", "websiteTalk", {
|
|
49
|
+
language: "English",
|
|
50
|
+
}),
|
|
51
|
+
).rejects.toThrow("No client config found for clientId: invalid-client");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -38,8 +38,8 @@ describe("db.clientsConfig", () => {
|
|
|
38
38
|
defaultBaseUrl: "https://acme.example",
|
|
39
39
|
scheduler: {
|
|
40
40
|
type: "cron",
|
|
41
|
-
schedule: "0 9 * * 1-5"
|
|
42
|
-
|
|
41
|
+
schedule: "0 9 * * 1-5",
|
|
42
|
+
},
|
|
43
43
|
},
|
|
44
44
|
},
|
|
45
45
|
});
|
|
@@ -94,10 +94,12 @@ describe("db.clientsConfig", () => {
|
|
|
94
94
|
const result = await getClientConfig(clientId);
|
|
95
95
|
|
|
96
96
|
// Then
|
|
97
|
-
expect(result?.products.municipal?.moked_106?.toolsPrompts).toMatchObject(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
expect(result?.products.municipal?.moked_106?.toolsPrompts).toMatchObject(
|
|
98
|
+
{
|
|
99
|
+
findSubjectsSystemPrompt: "Custom subjects prompt",
|
|
100
|
+
findStreetsSystemPrompt: "Custom streets prompt",
|
|
101
|
+
},
|
|
102
|
+
);
|
|
101
103
|
});
|
|
102
104
|
|
|
103
105
|
it("given moked_106 with findSubjectsSystemPrompt only when saved and retrieved then findStreetsSystemPrompt is undefined", async () => {
|
|
@@ -123,8 +125,14 @@ describe("db.clientsConfig", () => {
|
|
|
123
125
|
const result = await getClientConfig(clientId);
|
|
124
126
|
|
|
125
127
|
// Then
|
|
126
|
-
expect(
|
|
127
|
-
|
|
128
|
+
expect(
|
|
129
|
+
result?.products.municipal?.moked_106?.toolsPrompts
|
|
130
|
+
?.findSubjectsSystemPrompt,
|
|
131
|
+
).toBe("Subjects only prompt");
|
|
132
|
+
expect(
|
|
133
|
+
result?.products.municipal?.moked_106?.toolsPrompts
|
|
134
|
+
?.findStreetsSystemPrompt,
|
|
135
|
+
).toBeUndefined();
|
|
128
136
|
});
|
|
129
137
|
|
|
130
138
|
it("given moked_106 without toolsPrompts when saved and retrieved then toolsPrompts is undefined", async () => {
|
|
@@ -148,7 +156,9 @@ describe("db.clientsConfig", () => {
|
|
|
148
156
|
const result = await getClientConfig(clientId);
|
|
149
157
|
|
|
150
158
|
// Then
|
|
151
|
-
expect(
|
|
159
|
+
expect(
|
|
160
|
+
result?.products.municipal?.moked_106?.toolsPrompts,
|
|
161
|
+
).toBeUndefined();
|
|
152
162
|
});
|
|
153
163
|
|
|
154
164
|
it("should support communications config", async () => {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ClientConfigDoc,
|
|
3
|
+
getDb,
|
|
4
|
+
findClientByPhoneNumber,
|
|
5
|
+
Products,
|
|
6
|
+
KNOWN_PRODUCT_KEYS,
|
|
7
|
+
KnownProductKey,
|
|
8
|
+
} from "../index";
|
|
2
9
|
import { Collection } from "mongodb";
|
|
3
10
|
|
|
4
11
|
export const getClientsConfigCollection = (): Collection<ClientConfigDoc> => {
|
|
@@ -20,3 +27,29 @@ export const createClientConfigDoc = async (
|
|
|
20
27
|
): Promise<void> => {
|
|
21
28
|
await getClientsConfigCollection().insertOne(clientConfig);
|
|
22
29
|
};
|
|
30
|
+
|
|
31
|
+
export async function updateProductConfig<K extends KnownProductKey>(
|
|
32
|
+
clientId: string,
|
|
33
|
+
productKey: K,
|
|
34
|
+
updates: Partial<Products[K]>,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (!KNOWN_PRODUCT_KEYS.includes(productKey as KnownProductKey)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid product key: "${productKey}". Must be one of: ${KNOWN_PRODUCT_KEYS.join(", ")}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const setOperations: Record<string, unknown> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
44
|
+
setOperations[`products.${productKey}.${key}`] = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await getClientsConfigCollection().updateOne(
|
|
48
|
+
{ clientId },
|
|
49
|
+
{ $set: setOperations },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (result.matchedCount === 0) {
|
|
53
|
+
throw new Error(`No client config found for clientId: ${clientId}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WithId } from "mongodb";
|
|
2
|
+
import { CallLengthThresholds } from "src/utils/shared.types";
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Moked106 (MIS)
|
|
@@ -26,6 +27,13 @@ export type Moked106Config = {
|
|
|
26
27
|
toolsPrompts?: Moked106ToolPrompts;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
export const KNOWN_PRODUCT_KEYS = [
|
|
31
|
+
"municipal",
|
|
32
|
+
"clinics",
|
|
33
|
+
"websiteTalk",
|
|
34
|
+
] as const;
|
|
35
|
+
export type KnownProductKey = (typeof KNOWN_PRODUCT_KEYS)[number];
|
|
36
|
+
|
|
29
37
|
/** Product config for the MIS */
|
|
30
38
|
export type MunicipalProduct = {
|
|
31
39
|
moked_106?: Moked106Config;
|
|
@@ -55,7 +63,6 @@ export type WebsiteTalkProduct = {
|
|
|
55
63
|
timezone: string;
|
|
56
64
|
language: string;
|
|
57
65
|
defaultBaseUrl: string;
|
|
58
|
-
scheduler?: WebsiteTalkScheduler;
|
|
59
66
|
};
|
|
60
67
|
|
|
61
68
|
// ---------------------------------------------------------------------------
|
|
@@ -112,6 +119,7 @@ export type ClientConfig<P extends Products = Products> = {
|
|
|
112
119
|
products: P;
|
|
113
120
|
timezone?: string;
|
|
114
121
|
communications?: CommunicationsConfig;
|
|
122
|
+
callLengthThresholds?: CallLengthThresholds;
|
|
115
123
|
};
|
|
116
124
|
|
|
117
125
|
export type ClientConfigDoc<P extends Products = Products> = WithId<
|
|
@@ -61,7 +61,11 @@ describe("flowMongoSchema", () => {
|
|
|
61
61
|
"backgroundToolOnce",
|
|
62
62
|
]);
|
|
63
63
|
|
|
64
|
-
expect(toolItem.properties).toHaveProperty(
|
|
65
|
-
|
|
64
|
+
expect(toolItem.properties).toHaveProperty(
|
|
65
|
+
"backgroundContinuationInstructions",
|
|
66
|
+
);
|
|
67
|
+
expect(
|
|
68
|
+
toolItem.properties.backgroundContinuationInstructions.bsonType,
|
|
69
|
+
).toBe("string");
|
|
66
70
|
});
|
|
67
71
|
});
|
|
@@ -143,7 +143,7 @@ export const flowMongoSchema = {
|
|
|
143
143
|
bsonType: "string",
|
|
144
144
|
enum: ["backgroundToolAlways", "backgroundToolOnce"],
|
|
145
145
|
},
|
|
146
|
-
backgroundContinuationInstructions: { bsonType:
|
|
146
|
+
backgroundContinuationInstructions: { bsonType: "string" },
|
|
147
147
|
},
|
|
148
148
|
additionalProperties: false,
|
|
149
149
|
},
|
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
getClientPhoneData,
|
|
7
7
|
createPhoneNumberEntity,
|
|
8
8
|
createPurchasedPhoneNumber,
|
|
9
|
-
} from
|
|
10
|
-
import { getFlowsCollection } from
|
|
11
|
-
import { createFlow, createPhoneNumber } from
|
|
12
|
-
import { ObjectId } from
|
|
13
|
-
|
|
14
|
-
describe(
|
|
15
|
-
describe(
|
|
16
|
-
it(
|
|
9
|
+
} from "../phone_numbers.getter";
|
|
10
|
+
import { getFlowsCollection } from "../../flows/flows.getter";
|
|
11
|
+
import { createFlow, createPhoneNumber } from "../../../test-utils/factories";
|
|
12
|
+
import { ObjectId } from "mongodb";
|
|
13
|
+
|
|
14
|
+
describe("db.phoneNumbers", () => {
|
|
15
|
+
describe("getPhoneDataByPhoneNumber", () => {
|
|
16
|
+
it("return phone number data with flow", async () => {
|
|
17
17
|
const flow = createFlow({
|
|
18
18
|
clientId: "test-client-id",
|
|
19
19
|
conversationSettings: {
|
|
@@ -129,9 +129,9 @@ describe('db.phoneNumbers', () => {
|
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
describe(
|
|
133
|
-
it(
|
|
134
|
-
const clientId =
|
|
132
|
+
describe("getPhoneNumbersForFlows", () => {
|
|
133
|
+
it("returns all phone numbers for the client with flow ObjectIds, newest first", async () => {
|
|
134
|
+
const clientId = "flowsClient";
|
|
135
135
|
const flow1 = createFlow({ clientId });
|
|
136
136
|
const flow2 = createFlow({ clientId });
|
|
137
137
|
await getFlowsCollection().insertMany([flow1, flow2]);
|
|
@@ -139,16 +139,16 @@ describe('db.phoneNumbers', () => {
|
|
|
139
139
|
const older = createPhoneNumber({
|
|
140
140
|
client_id: clientId,
|
|
141
141
|
flow_id: flow1._id,
|
|
142
|
-
phone_number:
|
|
142
|
+
phone_number: "+100",
|
|
143
143
|
is_primary: true,
|
|
144
|
-
createdAt: new Date(
|
|
144
|
+
createdAt: new Date("2023-01-01"),
|
|
145
145
|
});
|
|
146
146
|
const newer = createPhoneNumber({
|
|
147
147
|
client_id: clientId,
|
|
148
148
|
flow_id: flow2._id,
|
|
149
|
-
phone_number:
|
|
149
|
+
phone_number: "+200",
|
|
150
150
|
is_primary: false,
|
|
151
|
-
createdAt: new Date(
|
|
151
|
+
createdAt: new Date("2023-06-01"),
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
await getPhoneNumbersCollection().insertMany([older, newer]);
|
|
@@ -158,27 +158,30 @@ describe('db.phoneNumbers', () => {
|
|
|
158
158
|
expect(result).toHaveLength(2);
|
|
159
159
|
expect(result[0]).toEqual({
|
|
160
160
|
flowId: String(flow2._id),
|
|
161
|
-
phoneNumber:
|
|
161
|
+
phoneNumber: "+200",
|
|
162
162
|
isPrimary: false,
|
|
163
163
|
});
|
|
164
164
|
expect(result[1]).toEqual({
|
|
165
165
|
flowId: String(flow1._id),
|
|
166
|
-
phoneNumber:
|
|
166
|
+
phoneNumber: "+100",
|
|
167
167
|
isPrimary: true,
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
it(
|
|
172
|
-
const result = await getPhoneNumbersForFlows(
|
|
171
|
+
it("returns an empty array when the client has no phone numbers", async () => {
|
|
172
|
+
const result = await getPhoneNumbersForFlows("noSuchClient");
|
|
173
173
|
expect(result).toEqual([]);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
-
it(
|
|
177
|
-
const clientA =
|
|
178
|
-
const clientB =
|
|
176
|
+
it("does not include phone numbers for other clients", async () => {
|
|
177
|
+
const clientA = "clientA";
|
|
178
|
+
const clientB = "clientB";
|
|
179
179
|
const flow = createFlow();
|
|
180
180
|
await getFlowsCollection().insertOne(flow);
|
|
181
|
-
const phone = createPhoneNumber({
|
|
181
|
+
const phone = createPhoneNumber({
|
|
182
|
+
client_id: clientA,
|
|
183
|
+
flow_id: flow._id,
|
|
184
|
+
});
|
|
182
185
|
await getPhoneNumbersCollection().insertOne(phone);
|
|
183
186
|
|
|
184
187
|
const result = await getPhoneNumbersForFlows(clientB);
|
|
@@ -186,9 +189,9 @@ describe('db.phoneNumbers', () => {
|
|
|
186
189
|
});
|
|
187
190
|
});
|
|
188
191
|
|
|
189
|
-
describe(
|
|
190
|
-
it(
|
|
191
|
-
const clientId =
|
|
192
|
+
describe("createPhoneNumberEntity", () => {
|
|
193
|
+
it("creates first phone number as primary", async () => {
|
|
194
|
+
const clientId = "newClient";
|
|
192
195
|
const flowId = new ObjectId().toHexString();
|
|
193
196
|
const phoneNumber = "+123456789";
|
|
194
197
|
|
|
@@ -215,14 +218,14 @@ describe('db.phoneNumbers', () => {
|
|
|
215
218
|
});
|
|
216
219
|
});
|
|
217
220
|
|
|
218
|
-
describe(
|
|
219
|
-
it(
|
|
220
|
-
const clientId =
|
|
221
|
+
describe("createPurchasedPhoneNumber", () => {
|
|
222
|
+
it("persists a phone_numbers document with provider payload and flow", async () => {
|
|
223
|
+
const clientId = "purchasedClientDb";
|
|
221
224
|
const flowId = new ObjectId().toHexString();
|
|
222
|
-
const phoneNumber =
|
|
225
|
+
const phoneNumber = "+15551234567";
|
|
223
226
|
const providerPayload = {
|
|
224
|
-
provider:
|
|
225
|
-
provider_sid:
|
|
227
|
+
provider: "twilio" as const,
|
|
228
|
+
provider_sid: "PNxxxxxxxx",
|
|
226
229
|
};
|
|
227
230
|
|
|
228
231
|
await createPurchasedPhoneNumber(
|
|
@@ -233,13 +236,15 @@ describe('db.phoneNumbers', () => {
|
|
|
233
236
|
);
|
|
234
237
|
|
|
235
238
|
// Re-read from Mongo to confirm insert succeeded (not only the function return value).
|
|
236
|
-
const stored = await getPhoneNumbersCollection().findOne({
|
|
239
|
+
const stored = await getPhoneNumbersCollection().findOne({
|
|
240
|
+
phone_number: phoneNumber,
|
|
241
|
+
});
|
|
237
242
|
expect(stored).not.toBeNull();
|
|
238
243
|
expect(stored).toMatchObject({
|
|
239
244
|
phone_number: phoneNumber,
|
|
240
245
|
client_id: clientId,
|
|
241
|
-
provider:
|
|
242
|
-
provider_sid:
|
|
246
|
+
provider: "twilio",
|
|
247
|
+
provider_sid: "PNxxxxxxxx",
|
|
243
248
|
flow_id: new ObjectId(flowId),
|
|
244
249
|
});
|
|
245
250
|
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export type * from
|
|
1
|
+
export * from "./phone_numbers.getter";
|
|
2
|
+
export type * from "./phone_numbers.types";
|