shuttlepro-shared 1.3.98 → 1.4.2

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.
@@ -0,0 +1,480 @@
1
+ const Call = require("../../models/Call");
2
+
3
+ class CallRepository {
4
+ constructor() {
5
+ this.pipelines = {
6
+ callStats: this._buildCallStatsPipeline(),
7
+ holdQueue: this._buildHoldQueuePipeline(),
8
+ };
9
+ }
10
+
11
+ // Create a new call without transaction
12
+ async createCall(callData) {
13
+ try {
14
+ const call = new Call({
15
+ callId: callData.callId,
16
+ callerName: callData.callerName,
17
+ callerNumber: callData.callerNumber,
18
+ whatsappSdp: callData.whatsappSdp,
19
+ status: callData.status || "pending",
20
+ autoAccepted: callData.autoAccepted || false,
21
+ metadata: callData.metadata || {},
22
+ tags: callData.tags || [],
23
+ priority: callData.priority || 0,
24
+ platformId: callData.platformId || null,
25
+ workspaceId: callData.workspaceId || null,
26
+ profileId: callData.profileId || null,
27
+ });
28
+
29
+ call.addToHistory("created", null, "Call created in system");
30
+ await call.save();
31
+
32
+ await this._updateCallMetrics("created");
33
+ return call;
34
+ } catch (error) {
35
+ console.error(`Failed to create call ${callData.callId}:`, error);
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ // Get call by ID
41
+ async getCall(callId, options = {}) {
42
+ try {
43
+ const query = Call.findOne({ callId });
44
+
45
+ if (options.lean) query.lean();
46
+
47
+ if (options.populate !== false) {
48
+ query.populate([
49
+ { path: "platformId", model: "Integration" },
50
+ { path: "workspaceId", model: "Workspace" },
51
+ { path: "profileId", model: "Profile" },
52
+ ]);
53
+ } else if (options.populate) {
54
+ query.populate(options.populate);
55
+ }
56
+
57
+ if (options.select) query.select(options.select);
58
+
59
+ return await query.exec();
60
+ } catch (error) {
61
+ console.error(`Failed to get call ${callId}:`, error);
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ // Bulk get calls
67
+ async getCalls(filters = {}, options = {}) {
68
+ try {
69
+ const {
70
+ status,
71
+ agentId,
72
+ dateRange,
73
+ priority,
74
+ tags,
75
+ platformId,
76
+ workspaceId,
77
+ profileId,
78
+ page = 1,
79
+ limit = 50,
80
+ sort = { createdAt: -1 },
81
+ } = filters;
82
+
83
+ const query = {};
84
+ if (status)
85
+ query.status = Array.isArray(status) ? { $in: status } : status;
86
+ if (agentId) query.agentId = agentId;
87
+ if (platformId) query.platformId = platformId;
88
+ if (workspaceId) query.workspaceId = workspaceId;
89
+ if (profileId) query.profileId = profileId;
90
+
91
+ if (dateRange) {
92
+ query.createdAt = {
93
+ $gte: new Date(dateRange.start),
94
+ $lte: new Date(dateRange.end),
95
+ };
96
+ }
97
+
98
+ if (priority !== undefined) query.priority = { $gte: priority };
99
+ if (tags?.length > 0) query.tags = { $in: tags };
100
+
101
+ const skip = (page - 1) * limit;
102
+
103
+ const [calls, total] = await Promise.all([
104
+ Call.find(query)
105
+ .sort(sort)
106
+ .skip(skip)
107
+ .limit(limit)
108
+ .lean(options.lean !== false)
109
+ .populate(
110
+ options.populate !== false
111
+ ? [
112
+ { path: "platformId", model: "Integration" },
113
+ { path: "workspaceId", model: "Workspace" },
114
+ { path: "profileId", model: "Profile" },
115
+ ]
116
+ : []
117
+ )
118
+ .exec(),
119
+ Call.countDocuments(query),
120
+ ]);
121
+
122
+ return {
123
+ calls,
124
+ pagination: {
125
+ page,
126
+ limit,
127
+ total,
128
+ pages: Math.ceil(total / limit),
129
+ },
130
+ };
131
+ } catch (error) {
132
+ console.error("Failed to get calls:", error);
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ // Update call
138
+ async updateCall(callId, updateData) {
139
+ try {
140
+ const call = await Call.findOne({ callId });
141
+ if (!call) throw new Error(`Call ${callId} not found`);
142
+
143
+ if (updateData.__v !== undefined && call.__v !== updateData.__v) {
144
+ throw new Error(`Concurrent update detected for call ${callId}`);
145
+ }
146
+
147
+ const allowedFields = [
148
+ "status",
149
+ "agentId",
150
+ "callerName",
151
+ "callerNumber",
152
+ "priority",
153
+ "tags",
154
+ "metadata",
155
+ "isOnHold",
156
+ "platformId",
157
+ "workspaceId",
158
+ "profileId",
159
+ ];
160
+
161
+ Object.keys(updateData).forEach((key) => {
162
+ if (allowedFields.includes(key)) {
163
+ call[key] = updateData[key];
164
+ }
165
+ });
166
+
167
+ if (updateData.status && updateData.status !== call.status) {
168
+ call.addToHistory(
169
+ updateData.status,
170
+ updateData.agentId || call.agentId,
171
+ updateData.reason || `Status changed to ${updateData.status}`
172
+ );
173
+ }
174
+
175
+ call.updateHeartbeat();
176
+ await call.save();
177
+ return call;
178
+ } catch (error) {
179
+ console.error(`Failed to update call ${callId}:`, error);
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ // Assign call to agent
185
+ async assignCallToAgent(callId, agentId) {
186
+ try {
187
+ const call = await Call.findOne({ callId });
188
+ if (!call) throw new Error(`Call ${callId} not found`);
189
+
190
+ if (call.status !== "pending" && call.status !== "hold") {
191
+ throw new Error(`Call ${callId} is not available for assignment`);
192
+ }
193
+
194
+ call.agentId = agentId;
195
+ call.status = "active";
196
+ call.startTime = new Date();
197
+ call.isOnHold = false;
198
+ call.addToHistory("answered", agentId, "Call answered by agent");
199
+ call.updateHeartbeat();
200
+
201
+ await call.save();
202
+ return call;
203
+ } catch (error) {
204
+ console.error(
205
+ `Failed to assign call ${callId} to agent ${agentId}:`,
206
+ error
207
+ );
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ // Bulk update calls
213
+ async bulkUpdateCalls(operations) {
214
+ try {
215
+ const bulkOps = operations.map((op) => ({
216
+ updateOne: {
217
+ filter: { callId: op.callId },
218
+ update: {
219
+ $set: {
220
+ ...op.updateData,
221
+ lastHeartbeat: new Date(),
222
+ },
223
+ $push: {
224
+ callHistory: {
225
+ timestamp: new Date(),
226
+ action: op.action || "bulk_update",
227
+ agentId: op.agentId,
228
+ details: op.details || "Bulk operation",
229
+ },
230
+ },
231
+ },
232
+ },
233
+ }));
234
+
235
+ return await Call.bulkWrite(bulkOps);
236
+ } catch (error) {
237
+ console.error("Failed to perform bulk update:", error);
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ // Hold queue with priority
243
+ async getHoldQueue(options = {}) {
244
+ try {
245
+ const pipeline = [
246
+ { $match: { status: "hold", isOnHold: true } },
247
+ {
248
+ $addFields: {
249
+ waitingTime: {
250
+ $divide: [{ $subtract: [new Date(), "$createdAt"] }, 1000 * 60],
251
+ },
252
+ priorityScore: {
253
+ $add: ["$priority", { $multiply: ["$waitingTime", 0.1] }],
254
+ },
255
+ },
256
+ },
257
+ { $sort: { priorityScore: -1, createdAt: 1 } },
258
+ ];
259
+
260
+ if (options.limit) pipeline.push({ $limit: options.limit });
261
+
262
+ return await Call.aggregate(pipeline);
263
+ } catch (error) {
264
+ console.error("Failed to get hold queue:", error);
265
+ throw error;
266
+ }
267
+ }
268
+
269
+ // Get available calls
270
+ async getAvailableCalls(options = {}) {
271
+ try {
272
+ return await Call.find({
273
+ status: { $in: ["pending", "hold"] },
274
+ agentId: null,
275
+ })
276
+ .sort({ priority: -1, createdAt: 1 })
277
+ .limit(options.limit || 20)
278
+ .lean(options.lean !== false);
279
+ } catch (error) {
280
+ console.error("Failed to get available calls:", error);
281
+ throw error;
282
+ }
283
+ }
284
+
285
+ // Advanced stats
286
+ async getAdvancedStatistics(timeframe = "today", groupBy = null) {
287
+ try {
288
+ const dateFilter = this._buildDateFilter(timeframe);
289
+
290
+ const pipeline = [
291
+ { $match: dateFilter },
292
+ {
293
+ $group: {
294
+ _id: groupBy ? `$${groupBy}` : null,
295
+ totalCalls: { $sum: 1 },
296
+ activeCalls: {
297
+ $sum: { $cond: [{ $in: ["$status", ["active", "hold"]] }, 1, 0] },
298
+ },
299
+ completedCalls: {
300
+ $sum: { $cond: [{ $eq: ["$status", "ended"] }, 1, 0] },
301
+ },
302
+ abandonedCalls: {
303
+ $sum: { $cond: [{ $eq: ["$status", "abandoned"] }, 1, 0] },
304
+ },
305
+ averageDuration: { $avg: { $ifNull: ["$duration", 0] } },
306
+ totalDuration: { $sum: { $ifNull: ["$duration", 0] } },
307
+ averageWaitTime: {
308
+ $avg: {
309
+ $divide: [
310
+ { $subtract: ["$startTime", "$createdAt"] },
311
+ 1000 * 60,
312
+ ],
313
+ },
314
+ },
315
+ },
316
+ },
317
+ {
318
+ $addFields: {
319
+ answerRate: {
320
+ $cond: [
321
+ { $gt: ["$totalCalls", 0] },
322
+ { $divide: ["$completedCalls", "$totalCalls"] },
323
+ 0,
324
+ ],
325
+ },
326
+ abandonRate: {
327
+ $cond: [
328
+ { $gt: ["$totalCalls", 0] },
329
+ { $divide: ["$abandonedCalls", "$totalCalls"] },
330
+ 0,
331
+ ],
332
+ },
333
+ },
334
+ },
335
+ ];
336
+
337
+ const result = await Call.aggregate(pipeline);
338
+ return result[0] || this._getEmptyStats();
339
+ } catch (error) {
340
+ console.error("Failed to get advanced statistics:", error);
341
+ throw error;
342
+ }
343
+ }
344
+
345
+ // Cleanup stale connections
346
+ async cleanupStaleConnections(timeoutMinutes = 5) {
347
+ try {
348
+ const cutoffTime = new Date(Date.now() - timeoutMinutes * 60 * 1000);
349
+
350
+ const staleCalls = await Call.find({
351
+ status: { $in: ["active", "hold", "pending"] },
352
+ lastHeartbeat: { $lt: cutoffTime },
353
+ });
354
+
355
+ const results = [];
356
+
357
+ for (const call of staleCalls) {
358
+ if (call.agentId) {
359
+ await this.moveCallToHoldQueue(call.callId, "Connection timeout");
360
+ results.push({ callId: call.callId, action: "moved_to_hold" });
361
+ } else {
362
+ await this.endCall(call.callId);
363
+ results.push({ callId: call.callId, action: "ended" });
364
+ }
365
+ }
366
+
367
+ console.log(`Cleaned up ${results.length} stale connections`);
368
+ return results;
369
+ } catch (error) {
370
+ console.error("Failed to cleanup stale connections:", error);
371
+ throw error;
372
+ }
373
+ }
374
+
375
+ _buildDateFilter(timeframe) {
376
+ const now = new Date();
377
+ switch (timeframe) {
378
+ case "today":
379
+ const today = new Date(now);
380
+ today.setHours(0, 0, 0, 0);
381
+ return { createdAt: { $gte: today } };
382
+ case "week":
383
+ const weekAgo = new Date(now);
384
+ weekAgo.setDate(weekAgo.getDate() - 7);
385
+ return { createdAt: { $gte: weekAgo } };
386
+ case "month":
387
+ const monthAgo = new Date(now);
388
+ monthAgo.setMonth(monthAgo.getMonth() - 1);
389
+ return { createdAt: { $gte: monthAgo } };
390
+ default:
391
+ return {};
392
+ }
393
+ }
394
+
395
+ _buildCallStatsPipeline() {
396
+ return [
397
+ {
398
+ $group: {
399
+ _id: "$status",
400
+ count: { $sum: 1 },
401
+ avgDuration: { $avg: "$duration" },
402
+ },
403
+ },
404
+ ];
405
+ }
406
+
407
+ _buildHoldQueuePipeline() {
408
+ return [
409
+ { $match: { status: "hold", isOnHold: true } },
410
+ { $sort: { priority: -1, createdAt: 1 } },
411
+ ];
412
+ }
413
+
414
+ _getEmptyStats() {
415
+ return {
416
+ totalCalls: 0,
417
+ activeCalls: 0,
418
+ completedCalls: 0,
419
+ abandonedCalls: 0,
420
+ averageDuration: 0,
421
+ totalDuration: 0,
422
+ averageWaitTime: 0,
423
+ answerRate: 0,
424
+ abandonRate: 0,
425
+ };
426
+ }
427
+
428
+ async _updateCallMetrics(action) {
429
+ try {
430
+ console.log(`Updating metrics for action: ${action}`);
431
+ } catch (error) {
432
+ console.error("Failed to update metrics:", error);
433
+ }
434
+ }
435
+
436
+ async moveCallToHoldQueue(callId, reason = "Agent disconnected") {
437
+ try {
438
+ const call = await Call.findOne({ callId });
439
+ if (!call) throw new Error(`Call ${callId} not found`);
440
+
441
+ const previousAgentId = call.agentId;
442
+
443
+ call.agentId = null;
444
+ call.status = "hold";
445
+ call.isOnHold = true;
446
+ call.autoAccepted = true;
447
+ call.isHoldConnection = true;
448
+ call.priority = Math.min(call.priority + 1, 10);
449
+ call.addToHistory("disconnected", previousAgentId, reason);
450
+ call.updateHeartbeat();
451
+
452
+ await call.save();
453
+ console.log(`Call ${callId} moved back to hold queue due to: ${reason}`);
454
+ return call;
455
+ } catch (error) {
456
+ console.error(`Failed to move call ${callId} to hold queue:`, error);
457
+ throw error;
458
+ }
459
+ }
460
+
461
+ async endCall(callId, agentId = null) {
462
+ try {
463
+ const call = await Call.findOne({ callId });
464
+ if (!call) throw new Error(`Call ${callId} not found`);
465
+
466
+ call.status = "ended";
467
+ call.endTime = new Date();
468
+ call.duration = call.currentDuration;
469
+ call.addToHistory("ended", agentId, "Call ended");
470
+
471
+ await call.save();
472
+ return call;
473
+ } catch (error) {
474
+ console.error(`Failed to end call ${callId}:`, error);
475
+ throw error;
476
+ }
477
+ }
478
+ }
479
+
480
+ module.exports = new CallRepository();
@@ -15,6 +15,7 @@ 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 callRepository = require("./call.repository");
18
19
 
19
20
  exports.module = {
20
21
  workspaceRepository,
@@ -34,4 +35,5 @@ exports.module = {
34
35
  customerProfileRepository,
35
36
  customerTimelineRepository,
36
37
  shopRepository,
38
+ callRepository,
37
39
  };
package/config/socket.js CHANGED
@@ -4,27 +4,46 @@ require("dotenv").config();
4
4
 
5
5
  let io;
6
6
  const namespaceSockets = {}; // Store different namespace sockets
7
+ const namespaceHandlers = {}; // Store custom handlers for each namespace
8
+
9
+ /**
10
+ * Register custom event handlers for a specific namespace
11
+ * @param {string} namespace - Namespace name
12
+ * @param {Function} handlerFunction - Function that sets up socket event handlers
13
+ */
14
+ const registerNamespaceHandlers = (namespace, handlerFunction) => {
15
+ namespaceHandlers[namespace] = handlerFunction;
16
+ console.log(`📋 Registered custom handlers for namespace: /${namespace}`);
17
+ };
7
18
 
8
19
  /**
9
20
  * Initialize Socket.IO server with namespaces
10
21
  * @param {http.Server} server - HTTP server instance
11
22
  * @param {string} namespaceParam - Socket namespace name (default: "conversation")
12
23
  * @param {string} redisChannel - Redis channel for this namespace (default: "socket_events")
24
+ * @param {Object} options - Additional options for namespace configuration
25
+ * @param {Function} options.customHandlers - Custom socket event handlers for this namespace
26
+ * @param {boolean} options.requireWorkspaceId - Whether workspace ID is required (default: true)
13
27
  * @returns {SocketIO.Namespace} - The initialized namespace
14
28
  */
15
29
  const initializeSocket = async (
16
30
  server,
17
31
  namespaceParam = "conversation",
18
- redisChannel = "socket_events"
32
+ redisChannel = "socket_events",
33
+ options = {}
19
34
  ) => {
35
+ const {
36
+ customHandlers,
37
+ requireWorkspaceId = true,
38
+ cors = {
39
+ origin: "*",
40
+ methods: ["GET", "POST"],
41
+ },
42
+ } = options;
43
+
20
44
  // Initialize Socket.IO server if not already done
21
45
  if (!io) {
22
- io = new Server(server, {
23
- cors: {
24
- origin: "*",
25
- methods: ["GET", "POST"],
26
- },
27
- });
46
+ io = new Server(server, { cors });
28
47
  console.log("✅ Socket.IO server initialized");
29
48
  }
30
49
 
@@ -49,21 +68,36 @@ const initializeSocket = async (
49
68
  await subscriber.subscribe(nsRedisChannel, (messageStr) => {
50
69
  try {
51
70
  const message = JSON.parse(messageStr);
52
- const { workspaceId, event, data } = message;
71
+ const { workspaceId, event, data, target } = message;
53
72
 
54
73
  console.log(`📥 Redis message received on ${nsRedisChannel}:`, {
55
74
  workspaceId,
56
75
  event,
76
+ target,
57
77
  });
58
78
 
59
- // Emit to specific workspace room in this namespace
60
- if (workspaceId) {
79
+ // Handle different targeting options
80
+ if (target === "broadcast") {
81
+ // Broadcast to all clients in this namespace
82
+ namespace.emit(event, data);
83
+ console.log(
84
+ `📢 Event ${event} broadcasted to all clients in /${namespaceParam}`
85
+ );
86
+ } else if (target && target.startsWith("socket:")) {
87
+ // Target specific socket ID
88
+ const socketId = target.replace("socket:", "");
89
+ namespace.to(socketId).emit(event, data);
90
+ console.log(
91
+ `📢 Event ${event} emitted to socket ${socketId} in /${namespaceParam}`
92
+ );
93
+ } else if (workspaceId) {
94
+ // Emit to specific workspace room in this namespace
61
95
  namespace.to(workspaceId).emit(event, data);
62
96
  console.log(
63
97
  `📢 Event ${event} emitted to workspace ${workspaceId} in /${namespaceParam}`
64
98
  );
65
99
  } else {
66
- // Broadcast to all clients in this namespace if no workspaceId specified
100
+ // Default: broadcast to all clients in this namespace
67
101
  namespace.emit(event, data);
68
102
  console.log(
69
103
  `📢 Event ${event} broadcasted to all clients in /${namespaceParam}`
@@ -79,20 +113,44 @@ const initializeSocket = async (
79
113
 
80
114
  // Handle new socket connections to this namespace
81
115
  namespace.on("connection", (socket) => {
82
- const { workspaceId } = socket.handshake.query;
116
+ const { workspaceId, callId, agentId } = socket.handshake.query;
83
117
 
84
- if (!workspaceId) {
85
- console.warn(`⚠️ Connection rejected: No workspaceId provided`);
118
+ // Workspace ID validation (can be disabled for certain namespaces)
119
+ if (requireWorkspaceId && !workspaceId) {
120
+ console.warn(
121
+ `⚠️ Connection rejected: No workspaceId provided for namespace /${namespaceParam}`
122
+ );
86
123
  socket.disconnect(true);
87
124
  return;
88
125
  }
89
126
 
90
127
  console.log(
91
- `✅ Client connected: ${socket.id} (Workspace: ${workspaceId}) in /${namespaceParam}`
128
+ `✅ Client connected: ${socket.id} (Workspace: ${
129
+ workspaceId || "N/A"
130
+ }, CallId: ${callId || "N/A"}) in /${namespaceParam}`
92
131
  );
93
132
 
94
- // Join workspace room
95
- socket.join(workspaceId);
133
+ // Join workspace room if workspaceId is provided
134
+ if (workspaceId) {
135
+ socket.join(workspaceId);
136
+ }
137
+
138
+ // Join additional rooms based on query parameters
139
+ if (callId) {
140
+ socket.join(`call:${callId}`);
141
+ }
142
+ if (agentId) {
143
+ socket.join(`agent:${agentId}`);
144
+ }
145
+
146
+ // Store socket metadata
147
+ socket.metadata = {
148
+ workspaceId,
149
+ callId,
150
+ agentId,
151
+ namespace: namespaceParam,
152
+ connectedAt: new Date(),
153
+ };
96
154
 
97
155
  // Send connection confirmation to client
98
156
  socket.emit("connected", {
@@ -100,25 +158,70 @@ const initializeSocket = async (
100
158
  socketId: socket.id,
101
159
  namespace: namespaceParam,
102
160
  workspaceId,
161
+ callId,
162
+ agentId,
103
163
  });
104
164
 
105
- // Handle custom events from clients
106
- socket.on("client_event", (data) => {
107
- console.log(`📥 Client event from ${socket.id}:`, data);
108
- // You can process client events here
109
- });
165
+ // Apply custom handlers if provided
166
+ if (customHandlers && typeof customHandlers === "function") {
167
+ try {
168
+ customHandlers(socket, namespace);
169
+ console.log(
170
+ `📋 Applied custom handlers for socket ${socket.id} in /${namespaceParam}`
171
+ );
172
+ } catch (error) {
173
+ console.error(
174
+ `❌ Error applying custom handlers for socket ${socket.id}:`,
175
+ error
176
+ );
177
+ }
178
+ }
179
+
180
+ // Apply registered namespace handlers
181
+ if (namespaceHandlers[namespaceParam]) {
182
+ try {
183
+ namespaceHandlers[namespaceParam](socket, namespace);
184
+ console.log(
185
+ `📋 Applied registered handlers for socket ${socket.id} in /${namespaceParam}`
186
+ );
187
+ } catch (error) {
188
+ console.error(
189
+ `❌ Error applying registered handlers for socket ${socket.id}:`,
190
+ error
191
+ );
192
+ }
193
+ }
194
+
195
+ // Default event handlers for backward compatibility
196
+ setupDefaultHandlers(socket, namespace, namespaceParam);
110
197
 
111
198
  // Handle disconnect
112
199
  socket.on("disconnect", (reason) => {
113
200
  console.log(
114
- `❌ Client disconnected: ${socket.id} (Workspace: ${workspaceId}, Reason: ${reason})`
201
+ `❌ Client disconnected: ${socket.id} (Workspace: ${
202
+ workspaceId || "N/A"
203
+ }, Reason: ${reason}) from /${namespaceParam}`
115
204
  );
116
- socket.leave(workspaceId);
205
+
206
+ // Leave all rooms
207
+ if (workspaceId) socket.leave(workspaceId);
208
+ if (callId) socket.leave(`call:${callId}`);
209
+ if (agentId) socket.leave(`agent:${agentId}`);
210
+
211
+ // Emit disconnect event to namespace for cleanup
212
+ namespace.emit("client_disconnected", {
213
+ socketId: socket.id,
214
+ metadata: socket.metadata,
215
+ reason,
216
+ });
117
217
  });
118
218
 
119
219
  // Handle errors
120
220
  socket.on("error", (error) => {
121
- console.error(`❌ Socket error for ${socket.id}:`, error);
221
+ console.error(
222
+ `❌ Socket error for ${socket.id} in /${namespaceParam}:`,
223
+ error
224
+ );
122
225
  });
123
226
  });
124
227
 
@@ -128,15 +231,64 @@ const initializeSocket = async (
128
231
  return namespace;
129
232
  };
130
233
 
234
+ /**
235
+ * Setup default event handlers for backward compatibility
236
+ * @param {Socket} socket - Socket.IO socket instance
237
+ * @param {Namespace} namespace - Socket.IO namespace instance
238
+ * @param {string} namespaceParam - Namespace name
239
+ */
240
+ const setupDefaultHandlers = (socket, namespace, namespaceParam) => {
241
+ // Handle custom events from clients (backward compatibility)
242
+ socket.on("client_event", (data) => {
243
+ console.log(
244
+ `📥 Client event from ${socket.id} in /${namespaceParam}:`,
245
+ data
246
+ );
247
+
248
+ // Emit to Redis for other instances to handle
249
+ sendEventToServer({
250
+ workspaceId: socket.metadata.workspaceId,
251
+ event: "client_event_received",
252
+ data: {
253
+ socketId: socket.id,
254
+ originalData: data,
255
+ metadata: socket.metadata,
256
+ },
257
+ namespace: namespaceParam,
258
+ }).catch((err) => {
259
+ console.error(`❌ Error publishing client_event to Redis:`, err);
260
+ });
261
+ });
262
+
263
+ // Handle room join requests
264
+ socket.on("join_room", (roomName) => {
265
+ if (roomName && typeof roomName === "string") {
266
+ socket.join(roomName);
267
+ socket.emit("room_joined", { room: roomName, success: true });
268
+ console.log(`📥 Socket ${socket.id} joined room: ${roomName}`);
269
+ }
270
+ });
271
+
272
+ // Handle room leave requests
273
+ socket.on("leave_room", (roomName) => {
274
+ if (roomName && typeof roomName === "string") {
275
+ socket.leave(roomName);
276
+ socket.emit("room_left", { room: roomName, success: true });
277
+ console.log(`📤 Socket ${socket.id} left room: ${roomName}`);
278
+ }
279
+ });
280
+ };
281
+
131
282
  /**
132
283
  * Send event to clients via Redis pub/sub
133
284
  * @param {object} params - Event parameters
134
- * @param {string} params.workspaceId - Target workspace ID
285
+ * @param {string} [params.workspaceId] - Target workspace ID
135
286
  * @param {string} params.event - Event name
136
287
  * @param {any} params.data - Event data payload
137
288
  * @param {string} [params.namespace="conversation"] - Target namespace
138
289
  * @param {string} [params.redisChannel="socket_events"] - Base Redis channel
139
- * @returns {Promise<void>}
290
+ * @param {string} [params.target] - Specific target (broadcast, socket:socketId, etc.)
291
+ * @returns {Promise<boolean>}
140
292
  */
141
293
  const sendEventToServer = async ({
142
294
  workspaceId,
@@ -144,6 +296,7 @@ const sendEventToServer = async ({
144
296
  data,
145
297
  namespace = "conversation",
146
298
  redisChannel = "socket_events",
299
+ target,
147
300
  }) => {
148
301
  try {
149
302
  // Create namespace-specific Redis channel
@@ -153,13 +306,21 @@ const sendEventToServer = async ({
153
306
  await connectRedis();
154
307
 
155
308
  // Create message payload
156
- const message = JSON.stringify({ workspaceId, event, data });
309
+ const message = JSON.stringify({
310
+ workspaceId,
311
+ event,
312
+ data,
313
+ target,
314
+ timestamp: new Date().toISOString(),
315
+ });
157
316
 
158
317
  // Publish to Redis
159
318
  await publisher.publish(nsRedisChannel, message);
160
319
 
161
320
  console.log(
162
- `📢 Event published to Redis channel ${nsRedisChannel}: ${event} (Workspace: ${workspaceId})`
321
+ `📢 Event published to Redis channel ${nsRedisChannel}: ${event} (Workspace: ${
322
+ workspaceId || "N/A"
323
+ }, Target: ${target || "default"})`
163
324
  );
164
325
 
165
326
  return true;
@@ -169,4 +330,62 @@ const sendEventToServer = async ({
169
330
  }
170
331
  };
171
332
 
172
- module.exports = { initializeSocket, sendEventToServer };
333
+ /**
334
+ * Get a specific namespace instance
335
+ * @param {string} namespace - Namespace name
336
+ * @returns {SocketIO.Namespace|null} - Namespace instance or null if not found
337
+ */
338
+ const getNamespace = (namespace) => {
339
+ return namespaceSockets[namespace] || null;
340
+ };
341
+
342
+ /**
343
+ * Get all active namespaces
344
+ * @returns {Object} - Object containing all active namespaces
345
+ */
346
+ const getAllNamespaces = () => {
347
+ return { ...namespaceSockets };
348
+ };
349
+
350
+ /**
351
+ * Get Socket.IO server instance
352
+ * @returns {SocketIO.Server|null} - Server instance or null if not initialized
353
+ */
354
+ const getSocketServer = () => {
355
+ return io || null;
356
+ };
357
+
358
+ /**
359
+ * Emit event to specific room in a namespace
360
+ * @param {string} namespace - Namespace name
361
+ * @param {string} room - Room name
362
+ * @param {string} event - Event name
363
+ * @param {any} data - Event data
364
+ * @returns {boolean} - Success status
365
+ */
366
+ const emitToRoom = (namespace, room, event, data) => {
367
+ try {
368
+ const ns = namespaceSockets[namespace];
369
+ if (!ns) {
370
+ console.warn(`❌ Namespace /${namespace} not found`);
371
+ return false;
372
+ }
373
+
374
+ ns.to(room).emit(event, data);
375
+ console.log(`📢 Event ${event} emitted to room ${room} in /${namespace}`);
376
+ return true;
377
+ } catch (error) {
378
+ console.error(`❌ Error emitting to room ${room} in /${namespace}:`, error);
379
+ return false;
380
+ }
381
+ };
382
+
383
+ module.exports = {
384
+ initializeSocket,
385
+ sendEventToServer,
386
+ registerNamespaceHandlers,
387
+ getNamespace,
388
+ getAllNamespaces,
389
+ getSocketServer,
390
+ emitToRoom,
391
+ };
@@ -10,11 +10,6 @@ const AttributeSchema = new Schema(
10
10
  ref: "Workspace",
11
11
  default: null,
12
12
  },
13
- websiteId: {
14
- type: Schema.Types.ObjectId,
15
- ref: "Website",
16
- default: null,
17
- },
18
13
  oldId: { type: String, default: "" },
19
14
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
20
15
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
@@ -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 },
@@ -168,7 +160,6 @@ const AutomationCondition = new Schema({
168
160
  "post",
169
161
  "shift",
170
162
  "newCommentPost",
171
- "profile",
172
163
  ],
173
164
  },
174
165
  keyValue: {
@@ -192,7 +183,6 @@ const AutomationCondition = new Schema({
192
183
  "orderConfirmation",
193
184
  "orderPublish",
194
185
  "newCommentPost",
195
- "profile",
196
186
  ],
197
187
  },
198
188
  subKeyValue: {
package/models/Call.js ADDED
@@ -0,0 +1,188 @@
1
+ const mongoose = require("mongoose");
2
+ const { Schema } = mongoose;
3
+
4
+ const callSchema = new mongoose.Schema(
5
+ {
6
+ callId: {
7
+ type: String,
8
+ required: true,
9
+ unique: true,
10
+ index: true,
11
+ },
12
+ callerName: {
13
+ type: String,
14
+ required: true,
15
+ },
16
+ callerNumber: {
17
+ type: String,
18
+ required: true,
19
+ index: true,
20
+ },
21
+ status: {
22
+ type: String,
23
+ enum: ["pending", "hold", "active", "ended", "disconnected"],
24
+ default: "pending",
25
+ index: true,
26
+ },
27
+ queuePosition: {
28
+ type: Number,
29
+ default: 0,
30
+ },
31
+ isOnHold: {
32
+ type: Boolean,
33
+ default: false,
34
+ index: true,
35
+ },
36
+ autoAccepted: {
37
+ type: Boolean,
38
+ default: false,
39
+ },
40
+ isHoldConnection: {
41
+ type: Boolean,
42
+ default: false,
43
+ },
44
+ agentId: {
45
+ type: String,
46
+ default: null,
47
+ index: true,
48
+ },
49
+ startTime: {
50
+ type: Date,
51
+ default: null,
52
+ },
53
+ endTime: {
54
+ type: Date,
55
+ default: null,
56
+ },
57
+ duration: {
58
+ type: Number, // in seconds
59
+ default: 0,
60
+ },
61
+ whatsappSdp: {
62
+ type: String,
63
+ default: null,
64
+ },
65
+ browserSdp: {
66
+ type: String,
67
+ default: null,
68
+ },
69
+ connectionMetadata: {
70
+ browserConnectionState: {
71
+ type: String,
72
+ default: "new",
73
+ },
74
+ whatsappConnectionState: {
75
+ type: String,
76
+ default: "new",
77
+ },
78
+ lastHeartbeat: {
79
+ type: Date,
80
+ default: Date.now,
81
+ },
82
+ },
83
+ type: { type: String, default: "inbound" },
84
+ callHistory: [
85
+ {
86
+ action: {
87
+ type: String,
88
+ enum: [
89
+ "created",
90
+ "answered",
91
+ "held",
92
+ "unheld",
93
+ "transferred",
94
+ "ended",
95
+ "disconnected",
96
+ "reconnected",
97
+ ],
98
+ },
99
+ timestamp: {
100
+ type: Date,
101
+ default: Date.now,
102
+ },
103
+ agentId: String,
104
+ details: String,
105
+ },
106
+ ],
107
+ platformId: {
108
+ type: Schema.Types.ObjectId,
109
+ ref: "Integration",
110
+ default: null,
111
+ },
112
+ workspaceId: { type: Schema.Types.ObjectId, ref: "Workspace" },
113
+ profileId: { type: Schema.Types.ObjectId, ref: "Profile" },
114
+ },
115
+ {
116
+ timestamps: true,
117
+ collection: "calls",
118
+ }
119
+ );
120
+
121
+ // Indexes for performance
122
+ callSchema.index({ status: 1, createdAt: 1 });
123
+ callSchema.index({ agentId: 1, status: 1 });
124
+ callSchema.index({ callerNumber: 1, createdAt: -1 });
125
+ callSchema.index({ isOnHold: 1, status: 1 });
126
+
127
+ // Virtual for call duration calculation
128
+ callSchema.virtual("currentDuration").get(function () {
129
+ if (this.startTime) {
130
+ const endTime = this.endTime || new Date();
131
+ return Math.floor((endTime - this.startTime) / 1000);
132
+ }
133
+ return 0;
134
+ });
135
+
136
+ // Methods
137
+ callSchema.methods.addToHistory = function (
138
+ action,
139
+ agentId = null,
140
+ details = null
141
+ ) {
142
+ this.callHistory.push({
143
+ action,
144
+ agentId,
145
+ details,
146
+ timestamp: new Date(),
147
+ });
148
+ };
149
+
150
+ callSchema.methods.updateHeartbeat = function () {
151
+ this.connectionMetadata.lastHeartbeat = new Date();
152
+ };
153
+
154
+ callSchema.methods.isStale = function (timeoutMinutes = 5) {
155
+ const timeout = timeoutMinutes * 60 * 1000; // Convert to milliseconds
156
+ return new Date() - this.connectionMetadata.lastHeartbeat > timeout;
157
+ };
158
+
159
+ // Static methods
160
+ callSchema.statics.getActiveCallsForAgent = function (agentId) {
161
+ return this.find({
162
+ agentId,
163
+ status: { $in: ["active", "hold"] },
164
+ }).sort({ createdAt: 1 });
165
+ };
166
+
167
+ callSchema.statics.getHoldQueue = function () {
168
+ return this.find({
169
+ status: "hold",
170
+ agentId: null,
171
+ }).sort({ createdAt: 1 });
172
+ };
173
+
174
+ callSchema.statics.getPendingCalls = function () {
175
+ return this.find({
176
+ status: "pending",
177
+ }).sort({ createdAt: 1 });
178
+ };
179
+
180
+ callSchema.statics.getStaleConnections = function (timeoutMinutes = 5) {
181
+ const timeout = new Date(Date.now() - timeoutMinutes * 60 * 1000);
182
+ return this.find({
183
+ status: { $in: ["active", "hold", "pending"] },
184
+ "connectionMetadata.lastHeartbeat": { $lt: timeout },
185
+ });
186
+ };
187
+
188
+ 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
  );
@@ -11,11 +11,6 @@ const CategorySchema = new Schema(
11
11
  ref: "Workspace",
12
12
  default: null,
13
13
  },
14
- websiteId: {
15
- type: Schema.Types.ObjectId,
16
- ref: "Website",
17
- default: null,
18
- },
19
14
  oldId: { type: String, default: "" },
20
15
  webCategoryId: { type: Number, default: null },
21
16
  parentId: { type: Schema.Types.ObjectId, ref: "Category", default: null },
@@ -14,11 +14,6 @@ const CheckpointSchema = new Schema(
14
14
  ref: "Workspace",
15
15
  default: null,
16
16
  },
17
- websiteId: {
18
- type: Schema.Types.ObjectId,
19
- ref: "Website",
20
- default: null,
21
- },
22
17
  },
23
18
  { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
24
19
  );
@@ -18,11 +18,6 @@ const CustomerSchema = new Schema(
18
18
  ref: "Workspace",
19
19
  default: null,
20
20
  },
21
- websiteId: {
22
- type: Schema.Types.ObjectId,
23
- ref: "Website",
24
- default: null,
25
- },
26
21
  isBlackListed: { type: Boolean, default: false },
27
22
  createdBy: {
28
23
  type: Schema.Types.ObjectId,
@@ -14,11 +14,6 @@ const LocationSchema = new Schema(
14
14
  oldId: { type: String, default: "" },
15
15
  webLocationId: { type: Number, default: null },
16
16
  bcLocationId: { type: String, default: "" },
17
- websiteId: {
18
- type: Schema.Types.ObjectId,
19
- ref: "Website",
20
- default: null,
21
- },
22
17
  workspaceId: {
23
18
  type: Schema.Types.ObjectId,
24
19
  ref: "Workspace",
package/models/Order.js CHANGED
@@ -103,11 +103,6 @@ const OrderSchema = new Schema(
103
103
  ref: "Workspace",
104
104
  default: null,
105
105
  },
106
- websiteId: {
107
- type: Schema.Types.ObjectId,
108
- ref: "Website",
109
- default: null,
110
- },
111
106
  orderType: { type: String, default: "" },
112
107
  barCode: { type: String, default: "" },
113
108
  quantity: { type: Number, default: 0 },
@@ -228,7 +223,6 @@ const markOrUnMarkOrderAsDuplicate = async (doc) => {
228
223
  const phoneRegex = getPhoneRegex(doc?.customerPhone);
229
224
  let orders = await Order.find({
230
225
  workspaceId: doc?.workspaceId,
231
- websiteId: doc?.websiteId,
232
226
  isDeleted: false,
233
227
  statusType: "pending",
234
228
  customerPhone: phoneRegex,
@@ -20,11 +20,6 @@ const OrderPdfScehma = new mongoose.Schema(
20
20
  ref: "Workspace",
21
21
  default: null,
22
22
  },
23
- websiteId: {
24
- type: Schema.Types.ObjectId,
25
- ref: "Website",
26
- default: null,
27
- },
28
23
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
29
24
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
30
25
  },
@@ -31,11 +31,6 @@ const OrderProductSchema = new Schema(
31
31
  ref: "Workspace",
32
32
  default: null,
33
33
  },
34
- websiteId: {
35
- type: Schema.Types.ObjectId,
36
- ref: "Website",
37
- default: null,
38
- },
39
34
  },
40
35
  { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
41
36
  );
package/models/Product.js CHANGED
@@ -31,11 +31,6 @@ const ProductSchema = new Schema(
31
31
  type: Schema.Types.ObjectId,
32
32
  ref: "Workspace",
33
33
  default: null,
34
- },
35
- websiteId: {
36
- type: Schema.Types.ObjectId,
37
- ref: "Website",
38
- default: null,
39
34
  },
40
35
  storeType: { type: String, default: "LOCAL" },
41
36
  isDeleted: { type: Boolean, default: false },
@@ -22,11 +22,6 @@ const ProductAttachmentSchema = new Schema(
22
22
  ref: "Workspace",
23
23
  default: null,
24
24
  },
25
- websiteId: {
26
- type: Schema.Types.ObjectId,
27
- ref: "Website",
28
- default: null,
29
- },
30
25
  height: { type: Number, default: 0 },
31
26
  variantIds: [
32
27
  { type: Schema.Types.ObjectId, ref: "ProductVariant", default: null },
@@ -16,11 +16,6 @@ const ProductAttributeSchema = new mongoose.Schema(
16
16
  ref: "Workspace",
17
17
  default: null,
18
18
  },
19
- websiteId: {
20
- type: Schema.Types.ObjectId,
21
- ref: "Website",
22
- default: null,
23
- },
24
19
  isDeleted: { type: Boolean, default: false },
25
20
  position: { type: String, default: "1" },
26
21
  },
@@ -11,11 +11,6 @@ const ProductCategorySchema = new Schema(
11
11
  ref: "Workspace",
12
12
  default: null,
13
13
  },
14
- websiteId: {
15
- type: Schema.Types.ObjectId,
16
- ref: "Website",
17
- default: null,
18
- },
19
14
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
20
15
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
21
16
  isDeleted: { type: Boolean, default: false },
@@ -11,11 +11,6 @@ const ProductTagSchema = new Schema(
11
11
  ref: "Workspace",
12
12
  default: null,
13
13
  },
14
- websiteId: {
15
- type: Schema.Types.ObjectId,
16
- ref: "Website",
17
- default: null,
18
- },
19
14
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
20
15
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
21
16
  isDeleted: { type: Boolean, default: false },
@@ -17,11 +17,6 @@ const ProductVariantSchema = new Schema(
17
17
  ref: "Workspace",
18
18
  default: null,
19
19
  },
20
- websiteId: {
21
- type: Schema.Types.ObjectId,
22
- ref: "Website",
23
- default: null,
24
- },
25
20
  webVariantId: { type: Number, default: null },
26
21
  inventoryPolicy: { type: String, default: "deny" }, //deny,continue
27
22
  taxable: { type: Boolean, default: false },
package/models/Tag.js CHANGED
@@ -8,11 +8,6 @@ const TagSchema = new Schema(
8
8
  ref: "Workspace",
9
9
  default: null,
10
10
  },
11
- websiteId: {
12
- type: Schema.Types.ObjectId,
13
- ref: "Website",
14
- default: null,
15
- },
16
11
  oldId: { type: String, default: "" },
17
12
  webTagId: { type: Number, default: "" },
18
13
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
@@ -13,11 +13,6 @@ const VariantLocationSchema = new Schema(
13
13
  ref: "Workspace",
14
14
  default: null,
15
15
  },
16
- websiteId: {
17
- type: Schema.Types.ObjectId,
18
- ref: "Website",
19
- default: null,
20
- },
21
16
  productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
22
17
  locationId: { type: Schema.Types.ObjectId, ref: "Location", default: null },
23
18
  webVariantLocationId: { type: Number, default: null },
package/models.js CHANGED
@@ -104,6 +104,7 @@ const WhatsappFlow = require("./models/WhatsappFlow");
104
104
  const Workflow = require("./models/Workflow");
105
105
  const Workspace = require("./models/Workspace");
106
106
  const Shop = require("./models/Shop");
107
+ const Call = require("./models/Call");
107
108
 
108
109
  module.exports = {
109
110
  Activity,
@@ -212,4 +213,5 @@ module.exports = {
212
213
  Workflow,
213
214
  Workspace,
214
215
  Shop,
216
+ Call,
215
217
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shuttlepro-shared",
3
- "version": "1.3.98",
3
+ "version": "1.4.02",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {