shuttlepro-shared 1.3.95 → 1.3.96
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 +540 -0
- package/common/repositories/index.js +3 -2
- package/models/Automation.js +0 -8
- package/models/Call.js +187 -0
- package/models/Card.js +0 -5
- package/models/Chatbot.js +0 -1
- package/models.js +2 -2
- package/package.json +1 -1
- package/common/repositories/interactive.repository.js +0 -124
- package/models/Interactive.js +0 -70
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
const Call = require("../../models/Call");
|
|
2
|
+
const mongoose = require("mongoose");
|
|
3
|
+
|
|
4
|
+
class CallRepository {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.pipelines = {
|
|
7
|
+
callStats: this._buildCallStatsPipeline(),
|
|
8
|
+
holdQueue: this._buildHoldQueuePipeline(),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Create a new call with transaction support
|
|
13
|
+
async createCall(callData) {
|
|
14
|
+
const session = await mongoose.startSession();
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return await session.withTransaction(async () => {
|
|
18
|
+
const call = new Call({
|
|
19
|
+
callId: callData.callId,
|
|
20
|
+
callerName: callData.callerName,
|
|
21
|
+
callerNumber: callData.callerNumber,
|
|
22
|
+
whatsappSdp: callData.whatsappSdp,
|
|
23
|
+
status: callData.status || "pending",
|
|
24
|
+
autoAccepted: callData.autoAccepted || false,
|
|
25
|
+
metadata: callData.metadata || {},
|
|
26
|
+
tags: callData.tags || [],
|
|
27
|
+
priority: callData.priority || 0,
|
|
28
|
+
platformId: callData.platformId || null,
|
|
29
|
+
workspaceId: callData.workspaceId || null,
|
|
30
|
+
profileId: callData.profileId || null,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
call.addToHistory("created", null, "Call created in system");
|
|
34
|
+
await call.save({ session });
|
|
35
|
+
|
|
36
|
+
await this._updateCallMetrics("created", session);
|
|
37
|
+
|
|
38
|
+
return call;
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`Failed to create call ${callData.callId}:`, error);
|
|
42
|
+
throw error;
|
|
43
|
+
} finally {
|
|
44
|
+
await session.endSession();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get call by ID
|
|
49
|
+
async getCall(callId, options = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const query = Call.findOne({ callId });
|
|
52
|
+
|
|
53
|
+
if (options.lean) query.lean();
|
|
54
|
+
|
|
55
|
+
// Auto-populate unless explicitly disabled
|
|
56
|
+
if (options.populate !== false) {
|
|
57
|
+
query.populate([
|
|
58
|
+
{ path: "platformId", model: "Integration" },
|
|
59
|
+
{ path: "workspaceId", model: "Workspace" },
|
|
60
|
+
{ path: "profileId", model: "Profile" },
|
|
61
|
+
]);
|
|
62
|
+
} else if (options.populate) {
|
|
63
|
+
query.populate(options.populate);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.select) query.select(options.select);
|
|
67
|
+
|
|
68
|
+
return await query.exec();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`Failed to get call ${callId}:`, error);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Bulk get calls with advanced filtering
|
|
76
|
+
async getCalls(filters = {}, options = {}) {
|
|
77
|
+
try {
|
|
78
|
+
const {
|
|
79
|
+
status,
|
|
80
|
+
agentId,
|
|
81
|
+
dateRange,
|
|
82
|
+
priority,
|
|
83
|
+
tags,
|
|
84
|
+
platformId,
|
|
85
|
+
workspaceId,
|
|
86
|
+
profileId,
|
|
87
|
+
page = 1,
|
|
88
|
+
limit = 50,
|
|
89
|
+
sort = { createdAt: -1 },
|
|
90
|
+
} = filters;
|
|
91
|
+
|
|
92
|
+
const query = {};
|
|
93
|
+
|
|
94
|
+
if (status)
|
|
95
|
+
query.status = Array.isArray(status) ? { $in: status } : status;
|
|
96
|
+
if (agentId) query.agentId = agentId;
|
|
97
|
+
if (platformId) query.platformId = platformId;
|
|
98
|
+
if (workspaceId) query.workspaceId = workspaceId;
|
|
99
|
+
if (profileId) query.profileId = profileId;
|
|
100
|
+
|
|
101
|
+
if (dateRange) {
|
|
102
|
+
query.createdAt = {
|
|
103
|
+
$gte: new Date(dateRange.start),
|
|
104
|
+
$lte: new Date(dateRange.end),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (priority !== undefined) query.priority = { $gte: priority };
|
|
109
|
+
if (tags && tags.length > 0) query.tags = { $in: tags };
|
|
110
|
+
|
|
111
|
+
const skip = (page - 1) * limit;
|
|
112
|
+
|
|
113
|
+
const [calls, total] = await Promise.all([
|
|
114
|
+
Call.find(query)
|
|
115
|
+
.sort(sort)
|
|
116
|
+
.skip(skip)
|
|
117
|
+
.limit(limit)
|
|
118
|
+
.lean(options.lean !== false)
|
|
119
|
+
.populate(
|
|
120
|
+
options.populate !== false
|
|
121
|
+
? [
|
|
122
|
+
{ path: "platformId", model: "Integration" },
|
|
123
|
+
{ path: "workspaceId", model: "Workspace" },
|
|
124
|
+
{ path: "profileId", model: "Profile" },
|
|
125
|
+
]
|
|
126
|
+
: []
|
|
127
|
+
)
|
|
128
|
+
.exec(),
|
|
129
|
+
Call.countDocuments(query),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
calls,
|
|
134
|
+
pagination: {
|
|
135
|
+
page,
|
|
136
|
+
limit,
|
|
137
|
+
total,
|
|
138
|
+
pages: Math.ceil(total / limit),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error("Failed to get calls:", error);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Update call with optimistic locking
|
|
148
|
+
async updateCall(callId, updateData, options = {}) {
|
|
149
|
+
const session = options.session || (await mongoose.startSession());
|
|
150
|
+
const shouldCommitSession = !options.session;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return await session.withTransaction(async () => {
|
|
154
|
+
const call = await Call.findOne({ callId }).session(session);
|
|
155
|
+
if (!call) throw new Error(`Call ${callId} not found`);
|
|
156
|
+
|
|
157
|
+
if (updateData.__v !== undefined && call.__v !== updateData.__v) {
|
|
158
|
+
throw new Error(`Concurrent update detected for call ${callId}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const allowedFields = [
|
|
162
|
+
"status",
|
|
163
|
+
"agentId",
|
|
164
|
+
"callerName",
|
|
165
|
+
"callerNumber",
|
|
166
|
+
"priority",
|
|
167
|
+
"tags",
|
|
168
|
+
"metadata",
|
|
169
|
+
"isOnHold",
|
|
170
|
+
"platformId",
|
|
171
|
+
"workspaceId",
|
|
172
|
+
"profileId",
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
Object.keys(updateData).forEach((key) => {
|
|
176
|
+
if (allowedFields.includes(key)) {
|
|
177
|
+
call[key] = updateData[key];
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (updateData.status && updateData.status !== call.status) {
|
|
182
|
+
call.addToHistory(
|
|
183
|
+
updateData.status,
|
|
184
|
+
updateData.agentId || call.agentId,
|
|
185
|
+
updateData.reason || `Status changed to ${updateData.status}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
call.updateHeartbeat();
|
|
190
|
+
await call.save({ session });
|
|
191
|
+
|
|
192
|
+
return call;
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
console.error(`Failed to update call ${callId}:`, error);
|
|
196
|
+
throw error;
|
|
197
|
+
} finally {
|
|
198
|
+
if (shouldCommitSession) await session.endSession();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Assign call to agent
|
|
203
|
+
async assignCallToAgent(callId, agentId, options = {}) {
|
|
204
|
+
const session = await mongoose.startSession();
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
return await session.withTransaction(async () => {
|
|
208
|
+
const call = await Call.findOne({ callId }).session(session);
|
|
209
|
+
if (!call) throw new Error(`Call ${callId} not found`);
|
|
210
|
+
|
|
211
|
+
if (call.status !== "pending" && call.status !== "hold") {
|
|
212
|
+
throw new Error(`Call ${callId} is not available for assignment`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
call.agentId = agentId;
|
|
216
|
+
call.status = "active";
|
|
217
|
+
call.startTime = new Date();
|
|
218
|
+
call.isOnHold = false;
|
|
219
|
+
call.addToHistory("answered", agentId, "Call answered by agent");
|
|
220
|
+
call.updateHeartbeat();
|
|
221
|
+
|
|
222
|
+
await call.save({ session });
|
|
223
|
+
return call;
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(
|
|
227
|
+
`Failed to assign call ${callId} to agent ${agentId}:`,
|
|
228
|
+
error
|
|
229
|
+
);
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
await session.endSession();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Bulk operations for call management
|
|
237
|
+
async bulkUpdateCalls(operations) {
|
|
238
|
+
const session = await mongoose.startSession();
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
return await session.withTransaction(async () => {
|
|
242
|
+
const bulkOps = operations.map((op) => ({
|
|
243
|
+
updateOne: {
|
|
244
|
+
filter: { callId: op.callId },
|
|
245
|
+
update: {
|
|
246
|
+
$set: {
|
|
247
|
+
...op.updateData,
|
|
248
|
+
lastHeartbeat: new Date(),
|
|
249
|
+
},
|
|
250
|
+
$push: {
|
|
251
|
+
callHistory: {
|
|
252
|
+
timestamp: new Date(),
|
|
253
|
+
action: op.action || "bulk_update",
|
|
254
|
+
agentId: op.agentId,
|
|
255
|
+
details: op.details || "Bulk operation",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
return await Call.bulkWrite(bulkOps, { session });
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error("Failed to perform bulk update:", error);
|
|
266
|
+
throw error;
|
|
267
|
+
} finally {
|
|
268
|
+
await session.endSession();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hold queue with priority
|
|
273
|
+
async getHoldQueue(options = {}) {
|
|
274
|
+
try {
|
|
275
|
+
const pipeline = [
|
|
276
|
+
{ $match: { status: "hold", isOnHold: true } },
|
|
277
|
+
{
|
|
278
|
+
$addFields: {
|
|
279
|
+
waitingTime: {
|
|
280
|
+
$divide: [{ $subtract: [new Date(), "$createdAt"] }, 1000 * 60],
|
|
281
|
+
},
|
|
282
|
+
priorityScore: {
|
|
283
|
+
$add: ["$priority", { $multiply: ["$waitingTime", 0.1] }],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{ $sort: { priorityScore: -1, createdAt: 1 } },
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
if (options.limit) pipeline.push({ $limit: options.limit });
|
|
291
|
+
|
|
292
|
+
return await Call.aggregate(pipeline);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error("Failed to get hold queue:", error);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get available calls
|
|
300
|
+
async getAvailableCalls(options = {}) {
|
|
301
|
+
try {
|
|
302
|
+
return await Call.find({
|
|
303
|
+
status: { $in: ["pending", "hold"] },
|
|
304
|
+
agentId: null,
|
|
305
|
+
})
|
|
306
|
+
.sort({ priority: -1, createdAt: 1 })
|
|
307
|
+
.limit(options.limit || 20)
|
|
308
|
+
.lean(options.lean !== false);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error("Failed to get available calls:", error);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Advanced stats
|
|
316
|
+
async getAdvancedStatistics(timeframe = "today", groupBy = null) {
|
|
317
|
+
try {
|
|
318
|
+
const dateFilter = this._buildDateFilter(timeframe);
|
|
319
|
+
|
|
320
|
+
const pipeline = [
|
|
321
|
+
{ $match: dateFilter },
|
|
322
|
+
{
|
|
323
|
+
$group: {
|
|
324
|
+
_id: groupBy ? `$${groupBy}` : null,
|
|
325
|
+
totalCalls: { $sum: 1 },
|
|
326
|
+
activeCalls: {
|
|
327
|
+
$sum: { $cond: [{ $in: ["$status", ["active", "hold"]] }, 1, 0] },
|
|
328
|
+
},
|
|
329
|
+
completedCalls: {
|
|
330
|
+
$sum: { $cond: [{ $eq: ["$status", "ended"] }, 1, 0] },
|
|
331
|
+
},
|
|
332
|
+
abandonedCalls: {
|
|
333
|
+
$sum: { $cond: [{ $eq: ["$status", "abandoned"] }, 1, 0] },
|
|
334
|
+
},
|
|
335
|
+
averageDuration: { $avg: { $ifNull: ["$duration", 0] } },
|
|
336
|
+
totalDuration: { $sum: { $ifNull: ["$duration", 0] } },
|
|
337
|
+
averageWaitTime: {
|
|
338
|
+
$avg: {
|
|
339
|
+
$divide: [
|
|
340
|
+
{ $subtract: ["$startTime", "$createdAt"] },
|
|
341
|
+
1000 * 60,
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
$addFields: {
|
|
349
|
+
answerRate: {
|
|
350
|
+
$cond: [
|
|
351
|
+
{ $gt: ["$totalCalls", 0] },
|
|
352
|
+
{ $divide: ["$completedCalls", "$totalCalls"] },
|
|
353
|
+
0,
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
abandonRate: {
|
|
357
|
+
$cond: [
|
|
358
|
+
{ $gt: ["$totalCalls", 0] },
|
|
359
|
+
{ $divide: ["$abandonedCalls", "$totalCalls"] },
|
|
360
|
+
0,
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const result = await Call.aggregate(pipeline);
|
|
368
|
+
return result[0] || this._getEmptyStats();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
console.error("Failed to get advanced statistics:", error);
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Cleanup stale connections
|
|
376
|
+
async cleanupStaleConnections(timeoutMinutes = 5) {
|
|
377
|
+
const session = await mongoose.startSession();
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
return await session.withTransaction(async () => {
|
|
381
|
+
const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000);
|
|
382
|
+
|
|
383
|
+
const staleCalls = await Call.find({
|
|
384
|
+
status: { $in: ["active", "hold", "pending"] },
|
|
385
|
+
lastHeartbeat: { $lt: cutoffTime },
|
|
386
|
+
}).session(session);
|
|
387
|
+
|
|
388
|
+
const results = [];
|
|
389
|
+
|
|
390
|
+
for (const call of staleCalls) {
|
|
391
|
+
if (call.agentId) {
|
|
392
|
+
await this.moveCallToHoldQueue(call.callId, "Connection timeout", {
|
|
393
|
+
session,
|
|
394
|
+
});
|
|
395
|
+
results.push({ callId: call.callId, action: "moved_to_hold" });
|
|
396
|
+
} else {
|
|
397
|
+
await this.endCall(call.callId, null, { session });
|
|
398
|
+
results.push({ callId: call.callId, action: "ended" });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
console.log(`Cleaned up ${results.length} stale connections`);
|
|
403
|
+
return results;
|
|
404
|
+
});
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error("Failed to cleanup stale connections:", error);
|
|
407
|
+
throw error;
|
|
408
|
+
} finally {
|
|
409
|
+
await session.endSession();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Helper methods
|
|
414
|
+
_buildDateFilter(timeframe) {
|
|
415
|
+
const now = new Date();
|
|
416
|
+
switch (timeframe) {
|
|
417
|
+
case "today":
|
|
418
|
+
const today = new Date(now);
|
|
419
|
+
today.setHours(0, 0, 0, 0);
|
|
420
|
+
return { createdAt: { $gte: today } };
|
|
421
|
+
case "week":
|
|
422
|
+
const weekAgo = new Date(now);
|
|
423
|
+
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
424
|
+
return { createdAt: { $gte: weekAgo } };
|
|
425
|
+
case "month":
|
|
426
|
+
const monthAgo = new Date(now);
|
|
427
|
+
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
|
428
|
+
return { createdAt: { $gte: monthAgo } };
|
|
429
|
+
default:
|
|
430
|
+
return {};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_buildCallStatsPipeline() {
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
$group: {
|
|
438
|
+
_id: "$status",
|
|
439
|
+
count: { $sum: 1 },
|
|
440
|
+
avgDuration: { $avg: "$duration" },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
_buildHoldQueuePipeline() {
|
|
447
|
+
return [
|
|
448
|
+
{ $match: { status: "hold", isOnHold: true } },
|
|
449
|
+
{ $sort: { priority: -1, createdAt: 1 } },
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_getEmptyStats() {
|
|
454
|
+
return {
|
|
455
|
+
totalCalls: 0,
|
|
456
|
+
activeCalls: 0,
|
|
457
|
+
completedCalls: 0,
|
|
458
|
+
abandonedCalls: 0,
|
|
459
|
+
averageDuration: 0,
|
|
460
|
+
totalDuration: 0,
|
|
461
|
+
averageWaitTime: 0,
|
|
462
|
+
answerRate: 0,
|
|
463
|
+
abandonRate: 0,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async _updateCallMetrics(action, session = null) {
|
|
468
|
+
try {
|
|
469
|
+
console.log(`Updating metrics for action: ${action}`);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.error("Failed to update metrics:", error);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async moveCallToHoldQueue(
|
|
476
|
+
callId,
|
|
477
|
+
reason = "Agent disconnected",
|
|
478
|
+
options = {}
|
|
479
|
+
) {
|
|
480
|
+
const session = options.session || (await mongoose.startSession());
|
|
481
|
+
const shouldCommitSession = !options.session;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
return await session.withTransaction(async () => {
|
|
485
|
+
const call = await Call.findOne({ callId }).session(session);
|
|
486
|
+
if (!call) throw new Error(`Call ${callId} not found`);
|
|
487
|
+
|
|
488
|
+
const previousAgentId = call.agentId;
|
|
489
|
+
|
|
490
|
+
call.agentId = null;
|
|
491
|
+
call.status = "hold";
|
|
492
|
+
call.isOnHold = true;
|
|
493
|
+
call.autoAccepted = true;
|
|
494
|
+
call.isHoldConnection = true;
|
|
495
|
+
call.priority = Math.min(call.priority + 1, 10);
|
|
496
|
+
call.addToHistory("disconnected", previousAgentId, reason);
|
|
497
|
+
call.updateHeartbeat();
|
|
498
|
+
|
|
499
|
+
await call.save({ session });
|
|
500
|
+
|
|
501
|
+
console.log(
|
|
502
|
+
`Call ${callId} moved back to hold queue due to: ${reason}`
|
|
503
|
+
);
|
|
504
|
+
return call;
|
|
505
|
+
});
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.error(`Failed to move call ${callId} to hold queue:`, error);
|
|
508
|
+
throw error;
|
|
509
|
+
} finally {
|
|
510
|
+
if (shouldCommitSession) await session.endSession();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async endCall(callId, agentId = null, options = {}) {
|
|
515
|
+
const session = options.session || (await mongoose.startSession());
|
|
516
|
+
const shouldCommitSession = !options.session;
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
return await session.withTransaction(async () => {
|
|
520
|
+
const call = await Call.findOne({ callId }).session(session);
|
|
521
|
+
if (!call) throw new Error(`Call ${callId} not found`);
|
|
522
|
+
|
|
523
|
+
call.status = "ended";
|
|
524
|
+
call.endTime = new Date();
|
|
525
|
+
call.duration = call.currentDuration;
|
|
526
|
+
call.addToHistory("ended", agentId, "Call ended");
|
|
527
|
+
|
|
528
|
+
await call.save({ session });
|
|
529
|
+
return call;
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
console.error(`Failed to end call ${callId}:`, error);
|
|
533
|
+
throw error;
|
|
534
|
+
} finally {
|
|
535
|
+
if (shouldCommitSession) await session.endSession();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
module.exports = new CallRepository();
|
|
@@ -15,7 +15,8 @@ const notificationSettingsRepository = require("./notificationSettings.repositor
|
|
|
15
15
|
const customerProfileRepository = require("./customerProfile.repository");
|
|
16
16
|
const customerTimelineRepository = require("./customerTimeline.repository");
|
|
17
17
|
const shopRepository = require("./shop.repository");
|
|
18
|
-
const
|
|
18
|
+
const callRepository = require("./call.repository");
|
|
19
|
+
|
|
19
20
|
exports.module = {
|
|
20
21
|
workspaceRepository,
|
|
21
22
|
integrationRepository,
|
|
@@ -34,5 +35,5 @@ exports.module = {
|
|
|
34
35
|
customerProfileRepository,
|
|
35
36
|
customerTimelineRepository,
|
|
36
37
|
shopRepository,
|
|
37
|
-
|
|
38
|
+
callRepository,
|
|
38
39
|
};
|
package/models/Automation.js
CHANGED
|
@@ -61,9 +61,6 @@ const AutomationAction = new Schema({
|
|
|
61
61
|
deleteComment: {
|
|
62
62
|
enabled: { type: Boolean, default: false },
|
|
63
63
|
},
|
|
64
|
-
hideComment: {
|
|
65
|
-
enabled: { type: Boolean, default: false },
|
|
66
|
-
},
|
|
67
64
|
closeChat: {
|
|
68
65
|
enabled: { type: Boolean, default: false },
|
|
69
66
|
},
|
|
@@ -99,11 +96,6 @@ const AutomationAction = new Schema({
|
|
|
99
96
|
default: null,
|
|
100
97
|
},
|
|
101
98
|
members: [userJoin],
|
|
102
|
-
templateFormId: {
|
|
103
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
104
|
-
ref: "FormTemplate",
|
|
105
|
-
default: null,
|
|
106
|
-
},
|
|
107
99
|
},
|
|
108
100
|
escalation: {
|
|
109
101
|
enabled: { type: Boolean, default: false },
|
package/models/Call.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const mongoose = require("mongoose");
|
|
2
|
+
|
|
3
|
+
const callSchema = new mongoose.Schema(
|
|
4
|
+
{
|
|
5
|
+
callId: {
|
|
6
|
+
type: String,
|
|
7
|
+
required: true,
|
|
8
|
+
unique: true,
|
|
9
|
+
index: true,
|
|
10
|
+
},
|
|
11
|
+
callerName: {
|
|
12
|
+
type: String,
|
|
13
|
+
required: true,
|
|
14
|
+
},
|
|
15
|
+
callerNumber: {
|
|
16
|
+
type: String,
|
|
17
|
+
required: true,
|
|
18
|
+
index: true,
|
|
19
|
+
},
|
|
20
|
+
status: {
|
|
21
|
+
type: String,
|
|
22
|
+
enum: ["pending", "hold", "active", "ended", "disconnected"],
|
|
23
|
+
default: "pending",
|
|
24
|
+
index: true,
|
|
25
|
+
},
|
|
26
|
+
queuePosition: {
|
|
27
|
+
type: Number,
|
|
28
|
+
default: 0,
|
|
29
|
+
},
|
|
30
|
+
isOnHold: {
|
|
31
|
+
type: Boolean,
|
|
32
|
+
default: false,
|
|
33
|
+
index: true,
|
|
34
|
+
},
|
|
35
|
+
autoAccepted: {
|
|
36
|
+
type: Boolean,
|
|
37
|
+
default: false,
|
|
38
|
+
},
|
|
39
|
+
isHoldConnection: {
|
|
40
|
+
type: Boolean,
|
|
41
|
+
default: false,
|
|
42
|
+
},
|
|
43
|
+
agentId: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: null,
|
|
46
|
+
index: true,
|
|
47
|
+
},
|
|
48
|
+
startTime: {
|
|
49
|
+
type: Date,
|
|
50
|
+
default: null,
|
|
51
|
+
},
|
|
52
|
+
endTime: {
|
|
53
|
+
type: Date,
|
|
54
|
+
default: null,
|
|
55
|
+
},
|
|
56
|
+
duration: {
|
|
57
|
+
type: Number, // in seconds
|
|
58
|
+
default: 0,
|
|
59
|
+
},
|
|
60
|
+
whatsappSdp: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: null,
|
|
63
|
+
},
|
|
64
|
+
browserSdp: {
|
|
65
|
+
type: String,
|
|
66
|
+
default: null,
|
|
67
|
+
},
|
|
68
|
+
connectionMetadata: {
|
|
69
|
+
browserConnectionState: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: "new",
|
|
72
|
+
},
|
|
73
|
+
whatsappConnectionState: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: "new",
|
|
76
|
+
},
|
|
77
|
+
lastHeartbeat: {
|
|
78
|
+
type: Date,
|
|
79
|
+
default: Date.now,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
type: { type: String, default: "inbound" },
|
|
83
|
+
callHistory: [
|
|
84
|
+
{
|
|
85
|
+
action: {
|
|
86
|
+
type: String,
|
|
87
|
+
enum: [
|
|
88
|
+
"created",
|
|
89
|
+
"answered",
|
|
90
|
+
"held",
|
|
91
|
+
"unheld",
|
|
92
|
+
"transferred",
|
|
93
|
+
"ended",
|
|
94
|
+
"disconnected",
|
|
95
|
+
"reconnected",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
timestamp: {
|
|
99
|
+
type: Date,
|
|
100
|
+
default: Date.now,
|
|
101
|
+
},
|
|
102
|
+
agentId: String,
|
|
103
|
+
details: String,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
platformId: {
|
|
107
|
+
type: Schema.Types.ObjectId,
|
|
108
|
+
ref: "Integration",
|
|
109
|
+
default: null,
|
|
110
|
+
},
|
|
111
|
+
workspaceId: { type: Schema.Types.ObjectId, ref: "Workspace" },
|
|
112
|
+
profileId: { type: Schema.Types.ObjectId, ref: "Profile" },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
timestamps: true,
|
|
116
|
+
collection: "calls",
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Indexes for performance
|
|
121
|
+
callSchema.index({ status: 1, createdAt: 1 });
|
|
122
|
+
callSchema.index({ agentId: 1, status: 1 });
|
|
123
|
+
callSchema.index({ callerNumber: 1, createdAt: -1 });
|
|
124
|
+
callSchema.index({ isOnHold: 1, status: 1 });
|
|
125
|
+
|
|
126
|
+
// Virtual for call duration calculation
|
|
127
|
+
callSchema.virtual("currentDuration").get(function () {
|
|
128
|
+
if (this.startTime) {
|
|
129
|
+
const endTime = this.endTime || new Date();
|
|
130
|
+
return Math.floor((endTime - this.startTime) / 1000);
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Methods
|
|
136
|
+
callSchema.methods.addToHistory = function (
|
|
137
|
+
action,
|
|
138
|
+
agentId = null,
|
|
139
|
+
details = null
|
|
140
|
+
) {
|
|
141
|
+
this.callHistory.push({
|
|
142
|
+
action,
|
|
143
|
+
agentId,
|
|
144
|
+
details,
|
|
145
|
+
timestamp: new Date(),
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
callSchema.methods.updateHeartbeat = function () {
|
|
150
|
+
this.connectionMetadata.lastHeartbeat = new Date();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
callSchema.methods.isStale = function (timeoutMinutes = 5) {
|
|
154
|
+
const timeout = timeoutMinutes * 60 * 1000; // Convert to milliseconds
|
|
155
|
+
return new Date() - this.connectionMetadata.lastHeartbeat > timeout;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Static methods
|
|
159
|
+
callSchema.statics.getActiveCallsForAgent = function (agentId) {
|
|
160
|
+
return this.find({
|
|
161
|
+
agentId,
|
|
162
|
+
status: { $in: ["active", "hold"] },
|
|
163
|
+
}).sort({ createdAt: 1 });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
callSchema.statics.getHoldQueue = function () {
|
|
167
|
+
return this.find({
|
|
168
|
+
status: "hold",
|
|
169
|
+
agentId: null,
|
|
170
|
+
}).sort({ createdAt: 1 });
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
callSchema.statics.getPendingCalls = function () {
|
|
174
|
+
return this.find({
|
|
175
|
+
status: "pending",
|
|
176
|
+
}).sort({ createdAt: 1 });
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
callSchema.statics.getStaleConnections = function (timeoutMinutes = 5) {
|
|
180
|
+
const timeout = new Date(Date.now() - timeoutMinutes * 60 * 1000);
|
|
181
|
+
return this.find({
|
|
182
|
+
status: { $in: ["active", "hold", "pending"] },
|
|
183
|
+
"connectionMetadata.lastHeartbeat": { $lt: timeout },
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
module.exports = mongoose.model("Call", callSchema);
|
package/models/Card.js
CHANGED
|
@@ -156,11 +156,6 @@ const cardSchema = new mongoose.Schema(
|
|
|
156
156
|
type: mongoose.Schema.Types.Mixed,
|
|
157
157
|
default: {},
|
|
158
158
|
},
|
|
159
|
-
templateFormId: {
|
|
160
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
161
|
-
ref: "FormTemplate",
|
|
162
|
-
default: null,
|
|
163
|
-
},
|
|
164
159
|
},
|
|
165
160
|
{ timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
|
|
166
161
|
);
|
package/models/Chatbot.js
CHANGED
package/models.js
CHANGED
|
@@ -43,7 +43,6 @@ const FormTemplate = require("./models/FormTemplate");
|
|
|
43
43
|
const GoogleClientInfo = require("./models/GoogleClientInfo");
|
|
44
44
|
const GoogleCredentials = require("./models/GoogleCredentials");
|
|
45
45
|
const Integration = require("./models/Integration");
|
|
46
|
-
const Interactive = require("./models/Interactive");
|
|
47
46
|
const InternalComments = require("./models/InternalComments");
|
|
48
47
|
const InternalThreads = require("./models/InternalThreads");
|
|
49
48
|
const JobDesign = require("./models/JobDesign");
|
|
@@ -105,6 +104,7 @@ const WhatsappFlow = require("./models/WhatsappFlow");
|
|
|
105
104
|
const Workflow = require("./models/Workflow");
|
|
106
105
|
const Workspace = require("./models/Workspace");
|
|
107
106
|
const Shop = require("./models/Shop");
|
|
107
|
+
const Call = require("./models/Call");
|
|
108
108
|
|
|
109
109
|
module.exports = {
|
|
110
110
|
Activity,
|
|
@@ -152,7 +152,6 @@ module.exports = {
|
|
|
152
152
|
GoogleClientInfo,
|
|
153
153
|
GoogleCredentials,
|
|
154
154
|
Integration,
|
|
155
|
-
Interactive,
|
|
156
155
|
InternalComments,
|
|
157
156
|
InternalThreads,
|
|
158
157
|
JobDesign,
|
|
@@ -214,4 +213,5 @@ module.exports = {
|
|
|
214
213
|
Workflow,
|
|
215
214
|
Workspace,
|
|
216
215
|
Shop,
|
|
216
|
+
Call,
|
|
217
217
|
};
|
package/package.json
CHANGED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// repositories/interactive.repository.js
|
|
2
|
-
const Interactive = require("../../models/Interactive");
|
|
3
|
-
const { getRedisData, setRedisData } = require("../../config/redis");
|
|
4
|
-
|
|
5
|
-
const CACHE_KEY_ALL = "interactive_all";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Get cached interactive for all workspaces.
|
|
9
|
-
*/
|
|
10
|
-
const getCachedAllInteractive = async () => {
|
|
11
|
-
let interactive = await getRedisData(CACHE_KEY_ALL);
|
|
12
|
-
if (!interactive) {
|
|
13
|
-
interactive = await Interactive.find({}).lean().exec();
|
|
14
|
-
await setRedisData(CACHE_KEY_ALL, interactive);
|
|
15
|
-
}
|
|
16
|
-
return interactive;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Update cached interactive for all workspaces.
|
|
21
|
-
*/
|
|
22
|
-
const updateCachedAllInteractive = async () => {
|
|
23
|
-
const interactive = await Interactive.find({}).lean().exec();
|
|
24
|
-
await setRedisData(CACHE_KEY_ALL, interactive);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Get interactive for a specific workspace from cache.
|
|
29
|
-
*/
|
|
30
|
-
const getWorkspaceInteractive = async (workspaceId) => {
|
|
31
|
-
if (!workspaceId) return [];
|
|
32
|
-
const allInteractive = await getCachedAllInteractive();
|
|
33
|
-
return allInteractive.filter(
|
|
34
|
-
(item) => item.workspaceId?.toString() === workspaceId.toString()
|
|
35
|
-
);
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
|
-
* Get interactive by ID (cache first).
|
|
39
|
-
*/
|
|
40
|
-
const getInteractiveById = async (id) => {
|
|
41
|
-
if (!id) return null;
|
|
42
|
-
|
|
43
|
-
const allInteractive = await getCachedAllInteractive();
|
|
44
|
-
let interactive = allInteractive.find(
|
|
45
|
-
(item) => item._id?.toString() === id.toString()
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// If not found in cache, fetch from DB and update cache
|
|
49
|
-
if (!interactive) {
|
|
50
|
-
interactive = await Interactive.findById(id).lean().exec();
|
|
51
|
-
if (interactive) {
|
|
52
|
-
await updateCachedAllInteractive();
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return interactive || null;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Create new interactive and refresh cache.
|
|
61
|
-
*/
|
|
62
|
-
const createInteractive = async (data) => {
|
|
63
|
-
const newInteractive = await Interactive.create(data);
|
|
64
|
-
if (newInteractive) {
|
|
65
|
-
await updateCachedAllInteractive();
|
|
66
|
-
}
|
|
67
|
-
return newInteractive;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Update interactive by ID and refresh cache.
|
|
72
|
-
*/
|
|
73
|
-
const updateInteractiveById = async (id, data) => {
|
|
74
|
-
const updated = await Interactive.findByIdAndUpdate(id, data, {
|
|
75
|
-
new: true,
|
|
76
|
-
}).exec();
|
|
77
|
-
if (updated) {
|
|
78
|
-
await updateCachedAllInteractive();
|
|
79
|
-
}
|
|
80
|
-
return updated;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Update interactive by filter and refresh cache.
|
|
85
|
-
*/
|
|
86
|
-
const updateSingleInteractiveByFilter = async (filter, data) => {
|
|
87
|
-
const updated = await Interactive.findOneAndUpdate(filter, data, {
|
|
88
|
-
new: true,
|
|
89
|
-
}).exec();
|
|
90
|
-
if (updated) {
|
|
91
|
-
await updateCachedAllInteractive();
|
|
92
|
-
}
|
|
93
|
-
return updated;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Delete interactive by ID and refresh cache.
|
|
98
|
-
*/
|
|
99
|
-
const deleteInteractiveById = async (id) => {
|
|
100
|
-
const deleted = await Interactive.findByIdAndDelete(id).exec();
|
|
101
|
-
if (deleted) {
|
|
102
|
-
await updateCachedAllInteractive();
|
|
103
|
-
}
|
|
104
|
-
return deleted;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Delete interactive by filter and refresh cache.
|
|
109
|
-
*/
|
|
110
|
-
const deleteInteractiveByFilter = async (filter) => {
|
|
111
|
-
await Interactive.deleteMany(filter).exec();
|
|
112
|
-
await updateCachedAllInteractive();
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
module.exports = {
|
|
116
|
-
updateCachedAllInteractive,
|
|
117
|
-
getWorkspaceInteractive,
|
|
118
|
-
createInteractive,
|
|
119
|
-
updateInteractiveById,
|
|
120
|
-
updateSingleInteractiveByFilter,
|
|
121
|
-
deleteInteractiveById,
|
|
122
|
-
deleteInteractiveByFilter,
|
|
123
|
-
getInteractiveById,
|
|
124
|
-
};
|
package/models/Interactive.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
const { Schema, Types, model } = require("mongoose");
|
|
2
|
-
|
|
3
|
-
const ActionSchema = new Schema({
|
|
4
|
-
type: {
|
|
5
|
-
type: String,
|
|
6
|
-
default: "",
|
|
7
|
-
},
|
|
8
|
-
interactiveId: { type: Types.ObjectId, ref: "Interactive", default: null },
|
|
9
|
-
value: { type: Schema.Types.Mixed, default: "" },
|
|
10
|
-
});
|
|
11
|
-
const InteractiveSchema = new Schema({
|
|
12
|
-
name: { type: String, default: "" },
|
|
13
|
-
workspace: { type: Types.ObjectId, ref: "Workspace", default: null },
|
|
14
|
-
type: {
|
|
15
|
-
type: String,
|
|
16
|
-
enum: ["button", "list"],
|
|
17
|
-
default: "list",
|
|
18
|
-
},
|
|
19
|
-
header: {
|
|
20
|
-
type: {
|
|
21
|
-
type: String,
|
|
22
|
-
enum: ["text", "image", "video"],
|
|
23
|
-
default: "text",
|
|
24
|
-
},
|
|
25
|
-
value: { type: String, default: "" },
|
|
26
|
-
},
|
|
27
|
-
body: {
|
|
28
|
-
type: {
|
|
29
|
-
type: String,
|
|
30
|
-
default: "text",
|
|
31
|
-
},
|
|
32
|
-
value: { type: String, default: "" },
|
|
33
|
-
},
|
|
34
|
-
footer: {
|
|
35
|
-
type: {
|
|
36
|
-
type: String,
|
|
37
|
-
default: "text",
|
|
38
|
-
},
|
|
39
|
-
value: { type: String, default: "" },
|
|
40
|
-
},
|
|
41
|
-
buttonTitle: {
|
|
42
|
-
type: String,
|
|
43
|
-
default: "Menu",
|
|
44
|
-
},
|
|
45
|
-
sections: [
|
|
46
|
-
{
|
|
47
|
-
title: { type: String, default: "" },
|
|
48
|
-
rows: [
|
|
49
|
-
{
|
|
50
|
-
id: { type: String, default: "" },
|
|
51
|
-
title: { type: String, default: "" },
|
|
52
|
-
description: { type: String, default: "" },
|
|
53
|
-
action: ActionSchema,
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
buttons: [
|
|
59
|
-
{
|
|
60
|
-
type: { type: String, default: "reply" },
|
|
61
|
-
reply: {
|
|
62
|
-
id: { type: String, default: "" },
|
|
63
|
-
title: { type: String, default: "" },
|
|
64
|
-
action: ActionSchema,
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
module.exports = model("Interactive", InteractiveSchema);
|