shuttlepro-shared 1.4.21 → 1.4.23
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/common/repositories/call.repository.js +211 -217
- package/package.json +1 -1
|
@@ -1,19 +1,84 @@
|
|
|
1
1
|
const Call = require("../../models/Call");
|
|
2
|
+
const { Types } = require("mongoose");
|
|
2
3
|
|
|
3
4
|
class CallRepository {
|
|
4
|
-
|
|
5
|
+
/* ----------------- Internal Helpers ----------------- */
|
|
6
|
+
|
|
7
|
+
_utcDayBounds(from, to) {
|
|
8
|
+
const f = new Date(from);
|
|
9
|
+
const t = new Date(to);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
from: new Date(
|
|
13
|
+
Date.UTC(
|
|
14
|
+
f.getUTCFullYear(),
|
|
15
|
+
f.getUTCMonth(),
|
|
16
|
+
f.getUTCDate(),
|
|
17
|
+
0,
|
|
18
|
+
0,
|
|
19
|
+
0,
|
|
20
|
+
0
|
|
21
|
+
)
|
|
22
|
+
),
|
|
23
|
+
to: new Date(
|
|
24
|
+
Date.UTC(
|
|
25
|
+
t.getUTCFullYear(),
|
|
26
|
+
t.getUTCMonth(),
|
|
27
|
+
t.getUTCDate(),
|
|
28
|
+
23,
|
|
29
|
+
59,
|
|
30
|
+
59,
|
|
31
|
+
999
|
|
32
|
+
)
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_filterAllowedFields(data, allowed) {
|
|
38
|
+
return Object.keys(data).reduce((acc, key) => {
|
|
39
|
+
if (allowed.includes(key)) acc[key] = data[key];
|
|
40
|
+
return acc;
|
|
41
|
+
}, {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_historyEntry({
|
|
45
|
+
action,
|
|
46
|
+
agentId = null,
|
|
47
|
+
details = "",
|
|
48
|
+
direction = "Agent Initiated",
|
|
49
|
+
platformTimestamp = "",
|
|
50
|
+
}) {
|
|
51
|
+
return {
|
|
52
|
+
timestamp: new Date(),
|
|
53
|
+
platformTimestamp,
|
|
54
|
+
action,
|
|
55
|
+
agentId,
|
|
56
|
+
details,
|
|
57
|
+
direction,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
5
60
|
|
|
6
|
-
async
|
|
61
|
+
async _safeExec(promise, errorMsg, fallback = null) {
|
|
7
62
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
63
|
+
return await promise;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`❌ ${errorMsg}`, error);
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ----------------- Core CRUD ----------------- */
|
|
71
|
+
|
|
72
|
+
createCall(callData) {
|
|
73
|
+
const entry = this._historyEntry({
|
|
74
|
+
action: "created",
|
|
75
|
+
direction: "User Initiated",
|
|
76
|
+
details: "Call created from webhook",
|
|
77
|
+
platformTimestamp: callData.platformTimestamp || "",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return this._safeExec(
|
|
81
|
+
Call.create({
|
|
17
82
|
callId: callData.callId,
|
|
18
83
|
callerName: callData.callerName,
|
|
19
84
|
callerNumber: callData.callerNumber,
|
|
@@ -27,97 +92,76 @@ class CallRepository {
|
|
|
27
92
|
whatsappSdp: callData.whatsappSdp || null,
|
|
28
93
|
metadata: callData.metadata || {},
|
|
29
94
|
tags: callData.tags || [],
|
|
30
|
-
callHistory: [
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
95
|
+
callHistory: [entry],
|
|
96
|
+
}),
|
|
97
|
+
`Failed to create call ${callData.callId}`
|
|
98
|
+
);
|
|
36
99
|
}
|
|
37
100
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
101
|
+
getCall(callId) {
|
|
102
|
+
return this._safeExec(
|
|
103
|
+
Call.findOne({ callId }).lean().exec(),
|
|
104
|
+
`Failed to get call ${callId}`
|
|
105
|
+
);
|
|
45
106
|
}
|
|
46
107
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
108
|
+
endCall(callId, agentId = null, updateObj = {}, history = {}) {
|
|
109
|
+
const entry = this._historyEntry({ ...history, agentId });
|
|
110
|
+
return this._safeExec(
|
|
111
|
+
Call.findOneAndUpdate(
|
|
50
112
|
{ callId },
|
|
51
|
-
{
|
|
52
|
-
$set: {
|
|
53
|
-
...updateObj,
|
|
54
|
-
},
|
|
55
|
-
$push: {
|
|
56
|
-
callHistory: {
|
|
57
|
-
...callHistory,
|
|
58
|
-
timestamp: new Date(),
|
|
59
|
-
agentId,
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
},
|
|
113
|
+
{ $set: { ...updateObj }, $push: { callHistory: entry } },
|
|
63
114
|
{ new: true }
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
115
|
+
),
|
|
116
|
+
`Failed to end call ${callId}`
|
|
117
|
+
);
|
|
69
118
|
}
|
|
70
119
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const updateOps = {
|
|
92
|
-
$set: {
|
|
93
|
-
...filteredUpdate,
|
|
94
|
-
"connectionMetadata.lastHeartbeat": new Date(),
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
if (updateData.status) {
|
|
99
|
-
updateOps.$push = {
|
|
100
|
-
callHistory: {
|
|
101
|
-
timestamp: new Date(),
|
|
102
|
-
action: updateData.status,
|
|
103
|
-
agentId: updateData.agentId || null,
|
|
104
|
-
details:
|
|
105
|
-
updateData.reason || `Status changed to ${updateData.status}`,
|
|
106
|
-
direction: "Agent Initiated",
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
}
|
|
120
|
+
updateCall(callId, updateData) {
|
|
121
|
+
const allowed = [
|
|
122
|
+
"status",
|
|
123
|
+
"agentId",
|
|
124
|
+
"callerName",
|
|
125
|
+
"callerNumber",
|
|
126
|
+
"tags",
|
|
127
|
+
"metadata",
|
|
128
|
+
"platformId",
|
|
129
|
+
"workspaceId",
|
|
130
|
+
"profileId",
|
|
131
|
+
"receiver",
|
|
132
|
+
];
|
|
133
|
+
const filtered = this._filterAllowedFields(updateData, allowed);
|
|
134
|
+
|
|
135
|
+
const updateOps = {
|
|
136
|
+
$set: { ...filtered, "connectionMetadata.lastHeartbeat": new Date() },
|
|
137
|
+
};
|
|
110
138
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
if (updateData.status) {
|
|
140
|
+
updateOps.$push = {
|
|
141
|
+
callHistory: this._historyEntry({
|
|
142
|
+
action: updateData.status,
|
|
143
|
+
agentId: updateData.agentId,
|
|
144
|
+
details:
|
|
145
|
+
updateData.reason || `Status changed to ${updateData.status}`,
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
115
148
|
}
|
|
149
|
+
|
|
150
|
+
return this._safeExec(
|
|
151
|
+
Call.findOneAndUpdate({ callId }, updateOps, { new: true }),
|
|
152
|
+
`Failed to update call ${callId}`
|
|
153
|
+
);
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
156
|
+
assignCallToAgent(callId, agentId) {
|
|
157
|
+
const entry = this._historyEntry({
|
|
158
|
+
action: "answered",
|
|
159
|
+
agentId,
|
|
160
|
+
details: "Call answered by agent",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return this._safeExec(
|
|
164
|
+
Call.findOneAndUpdate(
|
|
121
165
|
{ callId, status: { $in: ["pending", "hold"] } },
|
|
122
166
|
{
|
|
123
167
|
$set: {
|
|
@@ -126,54 +170,45 @@ class CallRepository {
|
|
|
126
170
|
startTime: new Date(),
|
|
127
171
|
"connectionMetadata.lastHeartbeat": new Date(),
|
|
128
172
|
},
|
|
129
|
-
$push: {
|
|
130
|
-
callHistory: {
|
|
131
|
-
timestamp: new Date(),
|
|
132
|
-
action: "answered",
|
|
133
|
-
agentId,
|
|
134
|
-
details: "Call answered by agent",
|
|
135
|
-
},
|
|
136
|
-
},
|
|
173
|
+
$push: { callHistory: entry },
|
|
137
174
|
},
|
|
138
175
|
{ new: true }
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
176
|
+
),
|
|
177
|
+
`Failed to assign call ${callId}`
|
|
178
|
+
);
|
|
144
179
|
}
|
|
145
180
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
details: op.details || "Bulk operation",
|
|
162
|
-
},
|
|
163
|
-
},
|
|
181
|
+
bulkUpdateCalls(operations) {
|
|
182
|
+
const bulkOps = operations.map((op) => ({
|
|
183
|
+
updateOne: {
|
|
184
|
+
filter: { callId: op.callId },
|
|
185
|
+
update: {
|
|
186
|
+
$set: {
|
|
187
|
+
...op.updateData,
|
|
188
|
+
"connectionMetadata.lastHeartbeat": new Date(),
|
|
189
|
+
},
|
|
190
|
+
$push: {
|
|
191
|
+
callHistory: this._historyEntry({
|
|
192
|
+
action: op.action || "bulk_update",
|
|
193
|
+
agentId: op.agentId,
|
|
194
|
+
details: op.details || "Bulk operation",
|
|
195
|
+
}),
|
|
164
196
|
},
|
|
165
197
|
},
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return [];
|
|
171
|
-
}
|
|
198
|
+
},
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
return this._safeExec(Call.bulkWrite(bulkOps), "Bulk update failed", []);
|
|
172
202
|
}
|
|
173
203
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
204
|
+
moveCallToHoldQueue(callId, reason = "Agent disconnected") {
|
|
205
|
+
const entry = this._historyEntry({
|
|
206
|
+
action: "disconnected",
|
|
207
|
+
details: reason,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return this._safeExec(
|
|
211
|
+
Call.findOneAndUpdate(
|
|
177
212
|
{ callId },
|
|
178
213
|
{
|
|
179
214
|
$set: {
|
|
@@ -182,94 +217,78 @@ class CallRepository {
|
|
|
182
217
|
autoAccepted: true,
|
|
183
218
|
"connectionMetadata.lastHeartbeat": new Date(),
|
|
184
219
|
},
|
|
185
|
-
$push: {
|
|
186
|
-
callHistory: {
|
|
187
|
-
timestamp: new Date(),
|
|
188
|
-
action: "disconnected",
|
|
189
|
-
agentId: null,
|
|
190
|
-
details: reason,
|
|
191
|
-
},
|
|
192
|
-
},
|
|
220
|
+
$push: { callHistory: entry },
|
|
193
221
|
},
|
|
194
222
|
{ new: true }
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
223
|
+
),
|
|
224
|
+
`Failed to move call ${callId} to hold queue`
|
|
225
|
+
);
|
|
200
226
|
}
|
|
201
227
|
|
|
202
|
-
/* -----------------
|
|
228
|
+
/* ----------------- Queries ----------------- */
|
|
203
229
|
|
|
204
230
|
async getCallsByWorkspace(
|
|
205
|
-
workspaceId,
|
|
231
|
+
{ workspaceId, from, to },
|
|
206
232
|
{
|
|
207
233
|
filters = {},
|
|
208
234
|
select = null,
|
|
209
235
|
sortBy = { createdAt: -1 },
|
|
210
|
-
limit
|
|
211
|
-
skip
|
|
236
|
+
limit,
|
|
237
|
+
skip,
|
|
212
238
|
} = {}
|
|
213
239
|
) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
240
|
+
const { from: fromDate, to: toDate } = this._utcDayBounds(from, to);
|
|
241
|
+
|
|
242
|
+
let query = Call.find({
|
|
243
|
+
workspaceId,
|
|
244
|
+
createdAt: { $gte: fromDate, $lte: toDate },
|
|
245
|
+
...filters,
|
|
246
|
+
})
|
|
247
|
+
.select(select || {})
|
|
248
|
+
.sort(sortBy)
|
|
217
249
|
.lean();
|
|
218
250
|
|
|
219
|
-
if (limit
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
if (skip !== null) {
|
|
223
|
-
query = query.skip(skip);
|
|
224
|
-
}
|
|
251
|
+
if (limit) query = query.limit(limit);
|
|
252
|
+
if (skip) query = query.skip(skip);
|
|
225
253
|
|
|
226
254
|
return query.exec();
|
|
227
255
|
}
|
|
228
256
|
|
|
229
|
-
|
|
257
|
+
getCallsByAgent(
|
|
230
258
|
agentId,
|
|
231
259
|
{
|
|
232
260
|
filters = {},
|
|
233
261
|
select = null,
|
|
234
262
|
sortBy = { createdAt: -1 },
|
|
235
|
-
limit
|
|
236
|
-
skip
|
|
263
|
+
limit,
|
|
264
|
+
skip,
|
|
237
265
|
} = {}
|
|
238
266
|
) {
|
|
239
267
|
let query = Call.find({ agentId, ...filters })
|
|
240
|
-
.select(select || {})
|
|
241
|
-
.sort(sortBy)
|
|
268
|
+
.select(select || {})
|
|
269
|
+
.sort(sortBy)
|
|
242
270
|
.lean();
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
query = query.limit(limit);
|
|
246
|
-
}
|
|
247
|
-
if (skip !== null) {
|
|
248
|
-
query = query.skip(skip);
|
|
249
|
-
}
|
|
250
|
-
|
|
271
|
+
if (limit) query = query.limit(limit);
|
|
272
|
+
if (skip) query = query.skip(skip);
|
|
251
273
|
return query.exec();
|
|
252
274
|
}
|
|
253
275
|
|
|
254
|
-
|
|
276
|
+
getCallsByReceiver(receiver, filters = {}) {
|
|
255
277
|
return Call.find({ receiver, ...filters })
|
|
256
278
|
.sort({ createdAt: -1 })
|
|
257
279
|
.lean()
|
|
258
280
|
.exec();
|
|
259
281
|
}
|
|
260
282
|
|
|
261
|
-
|
|
262
|
-
return Call.find({
|
|
263
|
-
workspaceId,
|
|
264
|
-
status: { $in: ["active", "hold"] },
|
|
265
|
-
});
|
|
283
|
+
getActiveCalls(workspaceId) {
|
|
284
|
+
return Call.find({ workspaceId, status: { $in: ["active", "hold"] } });
|
|
266
285
|
}
|
|
267
286
|
|
|
268
|
-
|
|
287
|
+
getPendingCalls(workspaceId) {
|
|
269
288
|
return Call.find({ workspaceId, status: "pending" });
|
|
270
289
|
}
|
|
271
290
|
|
|
272
|
-
|
|
291
|
+
getStaleCalls(timeoutMinutes = 5) {
|
|
273
292
|
const timeout = new Date(Date.now() - timeoutMinutes * 60 * 1000);
|
|
274
293
|
return Call.find({
|
|
275
294
|
status: { $in: ["active", "hold", "pending"] },
|
|
@@ -277,29 +296,19 @@ class CallRepository {
|
|
|
277
296
|
});
|
|
278
297
|
}
|
|
279
298
|
|
|
280
|
-
/* ----------------- Reporting
|
|
299
|
+
/* ----------------- Reporting ----------------- */
|
|
281
300
|
|
|
282
|
-
// Stats grouped by agent
|
|
283
301
|
async getAgentStats({ from, to }, filters = {}) {
|
|
284
|
-
|
|
285
|
-
const PAK_OFFSET = 5 * 60 * 60 * 1000;
|
|
286
|
-
|
|
287
|
-
// convert input to Date objects
|
|
288
|
-
const fromDate = new Date(new Date(from).getTime() - PAK_OFFSET);
|
|
289
|
-
const toDate = new Date(new Date(to).getTime() - PAK_OFFSET);
|
|
302
|
+
const { from: fromDate, to: toDate } = this._utcDayBounds(from, to);
|
|
290
303
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const matchStage = {
|
|
297
|
-
createdAt: { $gte: fromDate, $lte: toDate },
|
|
298
|
-
...filters,
|
|
299
|
-
};
|
|
304
|
+
const match = { createdAt: { $gte: fromDate, $lte: toDate }, ...filters };
|
|
305
|
+
if (match.workspaceId && typeof match.workspaceId === "string")
|
|
306
|
+
match.workspaceId = new Types.ObjectId(match.workspaceId);
|
|
307
|
+
if (match.agentId && typeof match.agentId === "string")
|
|
308
|
+
match.agentId = new Types.ObjectId(match.agentId);
|
|
300
309
|
|
|
301
|
-
const
|
|
302
|
-
{ $match:
|
|
310
|
+
const [stats] = await Call.aggregate([
|
|
311
|
+
{ $match: match },
|
|
303
312
|
{
|
|
304
313
|
$group: {
|
|
305
314
|
_id: filters.agentId ? "$agentId" : null,
|
|
@@ -312,25 +321,14 @@ class CallRepository {
|
|
|
312
321
|
},
|
|
313
322
|
},
|
|
314
323
|
{
|
|
315
|
-
$project: {
|
|
316
|
-
_id: 0,
|
|
317
|
-
totalCalls: 1,
|
|
318
|
-
pending: 1,
|
|
319
|
-
abandoned: 1,
|
|
320
|
-
ended: 1,
|
|
321
|
-
},
|
|
324
|
+
$project: { _id: 0, totalCalls: 1, pending: 1, abandoned: 1, ended: 1 },
|
|
322
325
|
},
|
|
323
326
|
]);
|
|
324
327
|
|
|
325
|
-
|
|
326
|
-
return { totalCalls: 0, pending: 0, abandoned: 0, ended: 0 };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return result[0];
|
|
328
|
+
return stats || { totalCalls: 0, pending: 0, abandoned: 0, ended: 0 };
|
|
330
329
|
}
|
|
331
330
|
|
|
332
|
-
|
|
333
|
-
async getDailySummary(workspaceId, from, to, filters = {}, groupBy = null) {
|
|
331
|
+
getDailySummary(workspaceId, from, to, filters = {}, groupBy = null) {
|
|
334
332
|
const matchStage = {
|
|
335
333
|
workspaceId,
|
|
336
334
|
createdAt: { $gte: from, $lte: to },
|
|
@@ -340,11 +338,8 @@ class CallRepository {
|
|
|
340
338
|
const groupId = {
|
|
341
339
|
day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
|
|
342
340
|
};
|
|
343
|
-
|
|
344
|
-
// If admin wants to group by agentId / receiver / platformId
|
|
345
|
-
if (groupBy && ["agentId", "receiver", "platformId"].includes(groupBy)) {
|
|
341
|
+
if (groupBy && ["agentId", "receiver", "platformId"].includes(groupBy))
|
|
346
342
|
groupId[groupBy] = `$${groupBy}`;
|
|
347
|
-
}
|
|
348
343
|
|
|
349
344
|
return Call.aggregate([
|
|
350
345
|
{ $match: matchStage },
|
|
@@ -369,7 +364,7 @@ class CallRepository {
|
|
|
369
364
|
total: 1,
|
|
370
365
|
ended: 1,
|
|
371
366
|
abandoned: 1,
|
|
372
|
-
avgDuration: { $round: ["$avgDuration", 2] },
|
|
367
|
+
avgDuration: { $round: ["$avgDuration", 2] },
|
|
373
368
|
minDuration: 1,
|
|
374
369
|
maxDuration: 1,
|
|
375
370
|
},
|
|
@@ -378,8 +373,7 @@ class CallRepository {
|
|
|
378
373
|
]);
|
|
379
374
|
}
|
|
380
375
|
|
|
381
|
-
|
|
382
|
-
async getQueueStats(workspaceId) {
|
|
376
|
+
getQueueStats(workspaceId) {
|
|
383
377
|
return Call.aggregate([
|
|
384
378
|
{ $match: { workspaceId, status: "hold" } },
|
|
385
379
|
{
|