shuttlepro-shared 1.4.36 → 1.4.37

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,432 @@
1
+ const Call = require("../../models/Call");
2
+ const { Types } = require("mongoose");
3
+
4
+ class CallRepository {
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 = "user",
49
+ platformTimestamp = "",
50
+ actionBy = null,
51
+ }) {
52
+ return {
53
+ timestamp: new Date(),
54
+ platformTimestamp,
55
+ action,
56
+ agentId,
57
+ details,
58
+ direction,
59
+ actionBy,
60
+ };
61
+ }
62
+
63
+ async _safeExec(promise, errorMsg, fallback = null) {
64
+ try {
65
+ return await promise;
66
+ } catch (error) {
67
+ console.error(`❌ ${errorMsg}`, error);
68
+ return fallback;
69
+ }
70
+ }
71
+
72
+ /* ----------------- Core CRUD ----------------- */
73
+
74
+ createCall(callData) {
75
+ const entry = this._historyEntry({
76
+ action: "created",
77
+ direction: callData?.direction || "user",
78
+ details: callData?.details || "Incomming call from whatsapp user",
79
+ platformTimestamp: callData.platformTimestamp || "",
80
+ agentId: callData.agentId || null,
81
+ actionBy: callData.userId || null,
82
+ });
83
+
84
+ return this._safeExec(
85
+ Call.create({
86
+ callId: callData.callId,
87
+ callerName: callData.callerName,
88
+ callerNumber: callData.callerNumber,
89
+ receiver: callData.receiver || "",
90
+ platformId: callData.platformId || null,
91
+ workspaceId: callData.workspaceId || null,
92
+ profileId: callData.profileId || null,
93
+ status: callData.status || "pending",
94
+ agentId: callData.agentId || null,
95
+ queuePosition: callData.queuePosition || 0,
96
+ whatsappSdp: callData.whatsappSdp || null,
97
+ metadata: callData.metadata || {},
98
+ tags: callData.tags || [],
99
+ callHistory: [entry],
100
+ }),
101
+ `Failed to create call ${callData.callId}`
102
+ );
103
+ }
104
+
105
+ getCall(callId) {
106
+ return this._safeExec(
107
+ Call.findOne({ callId }).lean().exec(),
108
+ `Failed to get call ${callId}`
109
+ );
110
+ }
111
+
112
+ endCall(callId, updateObj = {}, history = {}) {
113
+ return this._safeExec(
114
+ Call.findOneAndUpdate(
115
+ { callId },
116
+ { $set: { ...updateObj }, $push: { callHistory: history } },
117
+ { new: true }
118
+ ),
119
+ `Failed to end call ${callId}`
120
+ );
121
+ }
122
+
123
+ updateCall(callId, updateData, history = {}) {
124
+ const updateOps = {
125
+ $set: { ...updateData },
126
+ };
127
+ updateOps.$push = {
128
+ callHistory: history,
129
+ };
130
+ return this._safeExec(
131
+ Call.findOneAndUpdate({ callId }, updateOps, { new: true }),
132
+ `Failed to update call ${callId}`
133
+ );
134
+ }
135
+
136
+ assignCallToAgent(callId, agentId) {
137
+ const entry = this._historyEntry({
138
+ action: "answered",
139
+ agentId,
140
+ details: "Call answered by agent",
141
+ });
142
+
143
+ return this._safeExec(
144
+ Call.findOneAndUpdate(
145
+ { callId, status: { $in: ["pending", "hold"] } },
146
+ {
147
+ $set: {
148
+ agentId,
149
+ status: "active",
150
+ startTime: new Date(),
151
+ "connectionMetadata.lastHeartbeat": new Date(),
152
+ },
153
+ $push: { callHistory: entry },
154
+ },
155
+ { new: true }
156
+ ),
157
+ `Failed to assign call ${callId}`
158
+ );
159
+ }
160
+
161
+ bulkUpdateCalls(operations) {
162
+ const bulkOps = operations.map((op) => ({
163
+ updateOne: {
164
+ filter: { callId: op.callId },
165
+ update: {
166
+ $set: {
167
+ ...op.updateData,
168
+ "connectionMetadata.lastHeartbeat": new Date(),
169
+ },
170
+ $push: {
171
+ callHistory: this._historyEntry({
172
+ action: op.action || "bulk_update",
173
+ agentId: op.agentId,
174
+ details: op.details || "Bulk operation",
175
+ }),
176
+ },
177
+ },
178
+ },
179
+ }));
180
+
181
+ return this._safeExec(Call.bulkWrite(bulkOps), "Bulk update failed", []);
182
+ }
183
+
184
+ moveCallToHoldQueue(callId, reason = "Agent disconnected") {
185
+ const entry = this._historyEntry({
186
+ action: "disconnected",
187
+ details: reason,
188
+ });
189
+
190
+ return this._safeExec(
191
+ Call.findOneAndUpdate(
192
+ { callId },
193
+ {
194
+ $set: {
195
+ agentId: null,
196
+ status: "hold",
197
+ autoAccepted: true,
198
+ "connectionMetadata.lastHeartbeat": new Date(),
199
+ },
200
+ $push: { callHistory: entry },
201
+ },
202
+ { new: true }
203
+ ),
204
+ `Failed to move call ${callId} to hold queue`
205
+ );
206
+ }
207
+
208
+ /* ----------------- Queries ----------------- */
209
+
210
+ async getCallsByWorkspace(
211
+ { workspaceId, from, to },
212
+ {
213
+ filters = {},
214
+ select = null,
215
+ sortBy = { createdAt: -1 },
216
+ limit,
217
+ skip,
218
+ } = {}
219
+ ) {
220
+ const { from: fromDate, to: toDate } = this._utcDayBounds(from, to);
221
+
222
+ console.log(
223
+ {
224
+ workspaceId,
225
+ createdAt: { $gte: fromDate, $lte: toDate },
226
+ ...filters,
227
+ },
228
+ "query"
229
+ );
230
+
231
+ let query = Call.find({
232
+ workspaceId,
233
+ createdAt: { $gte: fromDate, $lte: toDate },
234
+ ...filters,
235
+ })
236
+ .select(select || {})
237
+ .sort(sortBy)
238
+ .lean();
239
+
240
+ if (limit) query = query.limit(limit);
241
+ if (skip) query = query.skip(skip);
242
+
243
+ return query.exec();
244
+ }
245
+
246
+ async addCallHistory(callId, historyData = {}) {
247
+ const entry = historyData;
248
+ return this._safeExec(
249
+ Call.findOneAndUpdate(
250
+ { callId },
251
+ { $push: { callHistory: entry } },
252
+ { new: true }
253
+ ),
254
+ `Failed to add call history for ${callId}`
255
+ );
256
+ }
257
+
258
+ getCallsByAgent(
259
+ agentId,
260
+ {
261
+ filters = {},
262
+ select = null,
263
+ sortBy = { createdAt: -1 },
264
+ limit,
265
+ skip,
266
+ } = {}
267
+ ) {
268
+ let query = Call.find({ agentId, ...filters })
269
+ .select(select || {})
270
+ .sort(sortBy)
271
+ .lean();
272
+ if (limit) query = query.limit(limit);
273
+ if (skip) query = query.skip(skip);
274
+ return query.exec();
275
+ }
276
+
277
+ getCallsByReceiver(receiver, filters = {}) {
278
+ return Call.find({ receiver, ...filters })
279
+ .sort({ createdAt: -1 })
280
+ .lean()
281
+ .exec();
282
+ }
283
+
284
+ getActiveCalls(workspaceId, filters = {}) {
285
+ return Call.find({
286
+ workspaceId,
287
+ status: { $in: ["active"] },
288
+ ...filters,
289
+ })
290
+ .lean()
291
+ .exec();
292
+ }
293
+
294
+ getPendingCalls(workspaceId) {
295
+ return Call.find({
296
+ workspaceId,
297
+ status: { $in: ["pending", "hold"] },
298
+ ...filters,
299
+ })
300
+ .lean()
301
+ .exec();
302
+ }
303
+
304
+ getCallByFilter(filters = {}) {
305
+ return Call.findOne(filters).lean().exec();
306
+ }
307
+
308
+ getStaleCalls(timeoutMinutes = 5) {
309
+ const timeout = new Date(Date.now() - timeoutMinutes * 60 * 1000);
310
+ return Call.find({
311
+ status: { $in: ["active", "hold", "pending"] },
312
+ "connectionMetadata.lastHeartbeat": { $lt: timeout },
313
+ })
314
+ .lean()
315
+ .exec();
316
+ }
317
+
318
+ /* ----------------- Reporting ----------------- */
319
+
320
+ async getAgentStats({ from, to }, filters = {}) {
321
+ const { from: fromDate, to: toDate } = this._utcDayBounds(from, to);
322
+
323
+ const match = { createdAt: { $gte: fromDate, $lte: toDate }, ...filters };
324
+ if (match.workspaceId && typeof match.workspaceId === "string")
325
+ match.workspaceId = new Types.ObjectId(match.workspaceId);
326
+ if (match.agentId && typeof match.agentId === "string")
327
+ match.agentId = match.agentId;
328
+
329
+ const [stats] = await Call.aggregate([
330
+ { $match: match },
331
+ {
332
+ $group: {
333
+ _id: filters.agentId ? "$agentId" : null,
334
+ totalCalls: { $sum: 1 },
335
+
336
+ pending: {
337
+ $sum: {
338
+ $cond: [{ $in: ["$status", ["pending", "hold"]] }, 1, 0],
339
+ },
340
+ },
341
+
342
+ active: {
343
+ $sum: {
344
+ $cond: [{ $eq: ["$status", "active"] }, 1, 0],
345
+ },
346
+ },
347
+
348
+ abandoned: {
349
+ $sum: { $cond: [{ $eq: ["$status", "abandoned"] }, 1, 0] },
350
+ },
351
+ ended: {
352
+ $sum: { $cond: [{ $eq: ["$status", "ended"] }, 1, 0] },
353
+ },
354
+ },
355
+ },
356
+ {
357
+ $project: {
358
+ _id: 0,
359
+ totalCalls: 1,
360
+ pending: 1,
361
+ active: 1,
362
+ abandoned: 1,
363
+ ended: 1,
364
+ },
365
+ },
366
+ ]);
367
+
368
+ return (
369
+ stats || { totalCalls: 0, pending: 0, active: 0, abandoned: 0, ended: 0 }
370
+ );
371
+ }
372
+
373
+ getDailySummary(workspaceId, from, to, filters = {}, groupBy = null) {
374
+ const matchStage = {
375
+ workspaceId,
376
+ createdAt: { $gte: from, $lte: to },
377
+ ...filters,
378
+ };
379
+
380
+ const groupId = {
381
+ day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
382
+ };
383
+ if (groupBy && ["agentId", "receiver", "platformId"].includes(groupBy))
384
+ groupId[groupBy] = `$${groupBy}`;
385
+
386
+ return Call.aggregate([
387
+ { $match: matchStage },
388
+ {
389
+ $group: {
390
+ _id: groupId,
391
+ total: { $sum: 1 },
392
+ ended: { $sum: { $cond: [{ $eq: ["$status", "ended"] }, 1, 0] } },
393
+ abandoned: {
394
+ $sum: { $cond: [{ $eq: ["$status", "abandoned"] }, 1, 0] },
395
+ },
396
+ avgDuration: { $avg: "$duration" },
397
+ minDuration: { $min: "$duration" },
398
+ maxDuration: { $max: "$duration" },
399
+ },
400
+ },
401
+ {
402
+ $project: {
403
+ _id: 0,
404
+ day: "$_id.day",
405
+ groupBy: groupBy ? `$_id.${groupBy}` : null,
406
+ total: 1,
407
+ ended: 1,
408
+ abandoned: 1,
409
+ avgDuration: { $round: ["$avgDuration", 2] },
410
+ minDuration: 1,
411
+ maxDuration: 1,
412
+ },
413
+ },
414
+ { $sort: { day: 1 } },
415
+ ]);
416
+ }
417
+
418
+ getQueueStats(workspaceId) {
419
+ return Call.aggregate([
420
+ { $match: { workspaceId, status: "hold" } },
421
+ {
422
+ $group: {
423
+ _id: "$receiver",
424
+ count: { $sum: 1 },
425
+ avgQueuePosition: { $avg: "$queuePosition" },
426
+ },
427
+ },
428
+ ]);
429
+ }
430
+ }
431
+
432
+ 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
  const interactiveRepository = require("./interactive.repository");
19
20
  const workflowRunRepository = require("./workflowRun.repository");
20
21
  exports.module = {
@@ -35,6 +36,7 @@ exports.module = {
35
36
  customerProfileRepository,
36
37
  customerTimelineRepository,
37
38
  shopRepository,
39
+ callRepository,
38
40
  interactiveRepository,
39
41
  workflowRunRepository,
40
42
  };
package/config/socket.js CHANGED
@@ -4,27 +4,47 @@ 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
+ path = "/whatsapp-calling",
34
+ options = {}
19
35
  ) => {
36
+ const {
37
+ customHandlers,
38
+ requireWorkspaceId = true,
39
+ cors = {
40
+ origin: "*",
41
+ methods: ["GET", "POST"],
42
+ },
43
+ } = options;
44
+
20
45
  // Initialize Socket.IO server if not already done
21
46
  if (!io) {
22
- io = new Server(server, {
23
- cors: {
24
- origin: "*",
25
- methods: ["GET", "POST"],
26
- },
27
- });
47
+ io = new Server(server, { path, cors });
28
48
  console.log("✅ Socket.IO server initialized");
29
49
  }
30
50
 
@@ -49,21 +69,36 @@ const initializeSocket = async (
49
69
  await subscriber.subscribe(nsRedisChannel, (messageStr) => {
50
70
  try {
51
71
  const message = JSON.parse(messageStr);
52
- const { workspaceId, event, data } = message;
72
+ const { workspaceId, event, data, target } = message;
53
73
 
54
74
  console.log(`📥 Redis message received on ${nsRedisChannel}:`, {
55
75
  workspaceId,
56
76
  event,
77
+ target,
57
78
  });
58
79
 
59
- // Emit to specific workspace room in this namespace
60
- if (workspaceId) {
80
+ // Handle different targeting options
81
+ if (target === "broadcast") {
82
+ // Broadcast to all clients in this namespace
83
+ namespace.emit(event, data);
84
+ console.log(
85
+ `📢 Event ${event} broadcasted to all clients in /${namespaceParam}`
86
+ );
87
+ } else if (target && target.startsWith("socket:")) {
88
+ // Target specific socket ID
89
+ const socketId = target.replace("socket:", "");
90
+ namespace.to(socketId).emit(event, data);
91
+ console.log(
92
+ `📢 Event ${event} emitted to socket ${socketId} in /${namespaceParam}`
93
+ );
94
+ } else if (workspaceId) {
95
+ // Emit to specific workspace room in this namespace
61
96
  namespace.to(workspaceId).emit(event, data);
62
97
  console.log(
63
98
  `📢 Event ${event} emitted to workspace ${workspaceId} in /${namespaceParam}`
64
99
  );
65
100
  } else {
66
- // Broadcast to all clients in this namespace if no workspaceId specified
101
+ // Default: broadcast to all clients in this namespace
67
102
  namespace.emit(event, data);
68
103
  console.log(
69
104
  `📢 Event ${event} broadcasted to all clients in /${namespaceParam}`
@@ -79,20 +114,44 @@ const initializeSocket = async (
79
114
 
80
115
  // Handle new socket connections to this namespace
81
116
  namespace.on("connection", (socket) => {
82
- const { workspaceId } = socket.handshake.query;
117
+ const { workspaceId, callId, agentId } = socket.handshake.query;
83
118
 
84
- if (!workspaceId) {
85
- console.warn(`⚠️ Connection rejected: No workspaceId provided`);
119
+ // Workspace ID validation (can be disabled for certain namespaces)
120
+ if (requireWorkspaceId && !workspaceId) {
121
+ console.warn(
122
+ `⚠️ Connection rejected: No workspaceId provided for namespace /${namespaceParam}`
123
+ );
86
124
  socket.disconnect(true);
87
125
  return;
88
126
  }
89
127
 
90
128
  console.log(
91
- `✅ Client connected: ${socket.id} (Workspace: ${workspaceId}) in /${namespaceParam}`
129
+ `✅ Client connected: ${socket.id} (Workspace: ${
130
+ workspaceId || "N/A"
131
+ }, CallId: ${callId || "N/A"}) in /${namespaceParam}`
92
132
  );
93
133
 
94
- // Join workspace room
95
- socket.join(workspaceId);
134
+ // Join workspace room if workspaceId is provided
135
+ if (workspaceId) {
136
+ socket.join(workspaceId);
137
+ }
138
+
139
+ // Join additional rooms based on query parameters
140
+ if (callId) {
141
+ socket.join(`call:${callId}`);
142
+ }
143
+ if (agentId) {
144
+ socket.join(`agent:${agentId}`);
145
+ }
146
+
147
+ // Store socket metadata
148
+ socket.metadata = {
149
+ workspaceId,
150
+ callId,
151
+ agentId,
152
+ namespace: namespaceParam,
153
+ connectedAt: new Date(),
154
+ };
96
155
 
97
156
  // Send connection confirmation to client
98
157
  socket.emit("connected", {
@@ -100,25 +159,70 @@ const initializeSocket = async (
100
159
  socketId: socket.id,
101
160
  namespace: namespaceParam,
102
161
  workspaceId,
162
+ callId,
163
+ agentId,
103
164
  });
104
165
 
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
- });
166
+ // Apply custom handlers if provided
167
+ if (customHandlers && typeof customHandlers === "function") {
168
+ try {
169
+ customHandlers(socket, namespace);
170
+ console.log(
171
+ `📋 Applied custom handlers for socket ${socket.id} in /${namespaceParam}`
172
+ );
173
+ } catch (error) {
174
+ console.error(
175
+ `❌ Error applying custom handlers for socket ${socket.id}:`,
176
+ error
177
+ );
178
+ }
179
+ }
180
+
181
+ // Apply registered namespace handlers
182
+ if (namespaceHandlers[namespaceParam]) {
183
+ try {
184
+ namespaceHandlers[namespaceParam](socket, namespace);
185
+ console.log(
186
+ `📋 Applied registered handlers for socket ${socket.id} in /${namespaceParam}`
187
+ );
188
+ } catch (error) {
189
+ console.error(
190
+ `❌ Error applying registered handlers for socket ${socket.id}:`,
191
+ error
192
+ );
193
+ }
194
+ }
195
+
196
+ // Default event handlers for backward compatibility
197
+ setupDefaultHandlers(socket, namespace, namespaceParam);
110
198
 
111
199
  // Handle disconnect
112
200
  socket.on("disconnect", (reason) => {
113
201
  console.log(
114
- `❌ Client disconnected: ${socket.id} (Workspace: ${workspaceId}, Reason: ${reason})`
202
+ `❌ Client disconnected: ${socket.id} (Workspace: ${
203
+ workspaceId || "N/A"
204
+ }, Reason: ${reason}) from /${namespaceParam}`
115
205
  );
116
- socket.leave(workspaceId);
206
+
207
+ // Leave all rooms
208
+ if (workspaceId) socket.leave(workspaceId);
209
+ if (callId) socket.leave(`call:${callId}`);
210
+ if (agentId) socket.leave(`agent:${agentId}`);
211
+
212
+ // Emit disconnect event to namespace for cleanup
213
+ namespace.emit("client_disconnected", {
214
+ socketId: socket.id,
215
+ metadata: socket.metadata,
216
+ reason,
217
+ });
117
218
  });
118
219
 
119
220
  // Handle errors
120
221
  socket.on("error", (error) => {
121
- console.error(`❌ Socket error for ${socket.id}:`, error);
222
+ console.error(
223
+ `❌ Socket error for ${socket.id} in /${namespaceParam}:`,
224
+ error
225
+ );
122
226
  });
123
227
  });
124
228
 
@@ -128,15 +232,64 @@ const initializeSocket = async (
128
232
  return namespace;
129
233
  };
130
234
 
235
+ /**
236
+ * Setup default event handlers for backward compatibility
237
+ * @param {Socket} socket - Socket.IO socket instance
238
+ * @param {Namespace} namespace - Socket.IO namespace instance
239
+ * @param {string} namespaceParam - Namespace name
240
+ */
241
+ const setupDefaultHandlers = (socket, namespace, namespaceParam) => {
242
+ // Handle custom events from clients (backward compatibility)
243
+ socket.on("client_event", (data) => {
244
+ console.log(
245
+ `📥 Client event from ${socket.id} in /${namespaceParam}:`,
246
+ data
247
+ );
248
+
249
+ // Emit to Redis for other instances to handle
250
+ sendEventToServer({
251
+ workspaceId: socket.metadata.workspaceId,
252
+ event: "client_event_received",
253
+ data: {
254
+ socketId: socket.id,
255
+ originalData: data,
256
+ metadata: socket.metadata,
257
+ },
258
+ namespace: namespaceParam,
259
+ }).catch((err) => {
260
+ console.error(`❌ Error publishing client_event to Redis:`, err);
261
+ });
262
+ });
263
+
264
+ // Handle room join requests
265
+ socket.on("join_room", (roomName) => {
266
+ if (roomName && typeof roomName === "string") {
267
+ socket.join(roomName);
268
+ socket.emit("room_joined", { room: roomName, success: true });
269
+ console.log(`📥 Socket ${socket.id} joined room: ${roomName}`);
270
+ }
271
+ });
272
+
273
+ // Handle room leave requests
274
+ socket.on("leave_room", (roomName) => {
275
+ if (roomName && typeof roomName === "string") {
276
+ socket.leave(roomName);
277
+ socket.emit("room_left", { room: roomName, success: true });
278
+ console.log(`📤 Socket ${socket.id} left room: ${roomName}`);
279
+ }
280
+ });
281
+ };
282
+
131
283
  /**
132
284
  * Send event to clients via Redis pub/sub
133
285
  * @param {object} params - Event parameters
134
- * @param {string} params.workspaceId - Target workspace ID
286
+ * @param {string} [params.workspaceId] - Target workspace ID
135
287
  * @param {string} params.event - Event name
136
288
  * @param {any} params.data - Event data payload
137
289
  * @param {string} [params.namespace="conversation"] - Target namespace
138
290
  * @param {string} [params.redisChannel="socket_events"] - Base Redis channel
139
- * @returns {Promise<void>}
291
+ * @param {string} [params.target] - Specific target (broadcast, socket:socketId, etc.)
292
+ * @returns {Promise<boolean>}
140
293
  */
141
294
  const sendEventToServer = async ({
142
295
  workspaceId,
@@ -144,6 +297,7 @@ const sendEventToServer = async ({
144
297
  data,
145
298
  namespace = "conversation",
146
299
  redisChannel = "socket_events",
300
+ target,
147
301
  }) => {
148
302
  try {
149
303
  // Create namespace-specific Redis channel
@@ -153,13 +307,21 @@ const sendEventToServer = async ({
153
307
  await connectRedis();
154
308
 
155
309
  // Create message payload
156
- const message = JSON.stringify({ workspaceId, event, data });
310
+ const message = JSON.stringify({
311
+ workspaceId,
312
+ event,
313
+ data,
314
+ target,
315
+ timestamp: new Date().toISOString(),
316
+ });
157
317
 
158
318
  // Publish to Redis
159
319
  await publisher.publish(nsRedisChannel, message);
160
320
 
161
321
  console.log(
162
- `📢 Event published to Redis channel ${nsRedisChannel}: ${event} (Workspace: ${workspaceId})`
322
+ `📢 Event published to Redis channel ${nsRedisChannel}: ${event} (Workspace: ${
323
+ workspaceId || "N/A"
324
+ }, Target: ${target || "default"})`
163
325
  );
164
326
 
165
327
  return true;
@@ -169,4 +331,62 @@ const sendEventToServer = async ({
169
331
  }
170
332
  };
171
333
 
172
- module.exports = { initializeSocket, sendEventToServer };
334
+ /**
335
+ * Get a specific namespace instance
336
+ * @param {string} namespace - Namespace name
337
+ * @returns {SocketIO.Namespace|null} - Namespace instance or null if not found
338
+ */
339
+ const getNamespace = (namespace) => {
340
+ return namespaceSockets[namespace] || null;
341
+ };
342
+
343
+ /**
344
+ * Get all active namespaces
345
+ * @returns {Object} - Object containing all active namespaces
346
+ */
347
+ const getAllNamespaces = () => {
348
+ return { ...namespaceSockets };
349
+ };
350
+
351
+ /**
352
+ * Get Socket.IO server instance
353
+ * @returns {SocketIO.Server|null} - Server instance or null if not initialized
354
+ */
355
+ const getSocketServer = () => {
356
+ return io || null;
357
+ };
358
+
359
+ /**
360
+ * Emit event to specific room in a namespace
361
+ * @param {string} namespace - Namespace name
362
+ * @param {string} room - Room name
363
+ * @param {string} event - Event name
364
+ * @param {any} data - Event data
365
+ * @returns {boolean} - Success status
366
+ */
367
+ const emitToRoom = (namespace, room, event, data) => {
368
+ try {
369
+ const ns = namespaceSockets[namespace];
370
+ if (!ns) {
371
+ console.warn(`❌ Namespace /${namespace} not found`);
372
+ return false;
373
+ }
374
+
375
+ ns.to(room).emit(event, data);
376
+ console.log(`📢 Event ${event} emitted to room ${room} in /${namespace}`);
377
+ return true;
378
+ } catch (error) {
379
+ console.error(`❌ Error emitting to room ${room} in /${namespace}:`, error);
380
+ return false;
381
+ }
382
+ };
383
+
384
+ module.exports = {
385
+ initializeSocket,
386
+ sendEventToServer,
387
+ registerNamespaceHandlers,
388
+ getNamespace,
389
+ getAllNamespaces,
390
+ getSocketServer,
391
+ emitToRoom,
392
+ };
@@ -10,6 +10,11 @@ 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
+ },
13
18
  oldId: { type: String, default: "" },
14
19
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
15
20
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
package/models/Call.js ADDED
@@ -0,0 +1,193 @@
1
+ const mongoose = require("mongoose");
2
+ const { Schema } = mongoose;
3
+
4
+ const callSchema = new mongoose.Schema(
5
+ {
6
+ workspaceId: { type: Schema.Types.ObjectId, ref: "Workspace" },
7
+ callId: {
8
+ type: String,
9
+ required: true,
10
+ unique: true,
11
+ index: true,
12
+ },
13
+ callerName: {
14
+ type: String,
15
+ required: true,
16
+ },
17
+ callerNumber: {
18
+ type: String,
19
+ required: true,
20
+ index: true,
21
+ },
22
+ profileId: { type: Schema.Types.ObjectId, ref: "Profile" },
23
+ receiver: {
24
+ type: String,
25
+ default: "",
26
+ },
27
+ platformId: {
28
+ type: Schema.Types.ObjectId,
29
+ ref: "Integration",
30
+ default: null,
31
+ },
32
+ type: { type: String, default: "inbound" },
33
+ status: {
34
+ type: String,
35
+ enum: ["pending", "hold", "active", "ended", "disconnected", "abandoned"],
36
+ default: "pending",
37
+ index: true,
38
+ },
39
+ queuePosition: {
40
+ type: Number,
41
+ default: 0,
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,
58
+ default: 0,
59
+ },
60
+ whatsappSdp: {
61
+ type: String,
62
+ default: null,
63
+ },
64
+ browserSdp: {
65
+ type: String,
66
+ default: null,
67
+ },
68
+ callHistory: [
69
+ {
70
+ action: {
71
+ type: String,
72
+ enum: [
73
+ "created",
74
+ "answered",
75
+ "held",
76
+ "unheld",
77
+ "transferred",
78
+ "ended",
79
+ "disconnected",
80
+ "reconnected",
81
+ "abandoned",
82
+ ],
83
+ },
84
+ direction: String, // USER INITIATED // AGENT INITIATED
85
+ agentId: String, // agent id
86
+ actionBy: String,
87
+ details: String, // "Status changed to hold"
88
+ startTime: String, // platform start time
89
+ endTime: String, // platform endTime time
90
+ duration: Number, // platform call duration
91
+ timestamp: {
92
+ // system timestamp
93
+ type: Date,
94
+ default: Date.now,
95
+ },
96
+ platformTimestamp: String, // platformtimestamp
97
+ },
98
+ ],
99
+ metadata: {},
100
+ tags: [],
101
+ connectionMetadata: {
102
+ browserConnectionState: {
103
+ type: String,
104
+ default: "new",
105
+ },
106
+ whatsappConnectionState: {
107
+ type: String,
108
+ default: "new",
109
+ },
110
+ lastHeartbeat: {
111
+ type: Date,
112
+ default: Date.now,
113
+ },
114
+ },
115
+ rating: {
116
+ type: Number,
117
+ default: 0,
118
+ },
119
+ },
120
+ {
121
+ timestamps: true,
122
+ collection: "calls",
123
+ }
124
+ );
125
+
126
+ // Indexes for performance
127
+ callSchema.index({ status: 1, createdAt: 1 });
128
+ callSchema.index({ agentId: 1, status: 1 });
129
+ callSchema.index({ callerNumber: 1, createdAt: -1 });
130
+ callSchema.index({ isOnHold: 1, status: 1 });
131
+
132
+ // Virtual for call duration calculation
133
+ callSchema.virtual("currentDuration").get(function () {
134
+ if (this.startTime) {
135
+ const endTime = this.endTime || new Date();
136
+ return Math.floor((endTime - this.startTime) / 1000);
137
+ }
138
+ return 0;
139
+ });
140
+
141
+ // Methods
142
+ callSchema.methods.addToHistory = function (
143
+ action,
144
+ agentId = null,
145
+ details = null
146
+ ) {
147
+ this.callHistory.push({
148
+ action,
149
+ agentId,
150
+ details,
151
+ timestamp: new Date(),
152
+ });
153
+ };
154
+
155
+ callSchema.methods.updateHeartbeat = function () {
156
+ this.connectionMetadata.lastHeartbeat = new Date();
157
+ };
158
+
159
+ callSchema.methods.isStale = function (timeoutMinutes = 5) {
160
+ const timeout = timeoutMinutes * 60 * 1000; // Convert to milliseconds
161
+ return new Date() - this.connectionMetadata.lastHeartbeat > timeout;
162
+ };
163
+
164
+ // Static methods
165
+ callSchema.statics.getActiveCallsForAgent = function (agentId) {
166
+ return this.find({
167
+ agentId,
168
+ status: { $in: ["active", "hold"] },
169
+ }).sort({ createdAt: 1 });
170
+ };
171
+
172
+ callSchema.statics.getHoldQueue = function () {
173
+ return this.find({
174
+ status: "hold",
175
+ agentId: null,
176
+ }).sort({ createdAt: 1 });
177
+ };
178
+
179
+ callSchema.statics.getPendingCalls = function () {
180
+ return this.find({
181
+ status: "pending",
182
+ }).sort({ createdAt: 1 });
183
+ };
184
+
185
+ callSchema.statics.getStaleConnections = function (timeoutMinutes = 5) {
186
+ const timeout = new Date(Date.now() - timeoutMinutes * 60 * 1000);
187
+ return this.find({
188
+ status: { $in: ["active", "hold", "pending"] },
189
+ "connectionMetadata.lastHeartbeat": { $lt: timeout },
190
+ });
191
+ };
192
+
193
+ module.exports = mongoose.model("Call", callSchema);
@@ -11,6 +11,11 @@ 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
+ },
14
19
  oldId: { type: String, default: "" },
15
20
  webCategoryId: { type: Number, default: null },
16
21
  parentId: { type: Schema.Types.ObjectId, ref: "Category", default: null },
@@ -14,6 +14,11 @@ 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
+ },
17
22
  },
18
23
  { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
19
24
  );
@@ -18,6 +18,11 @@ 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
+ },
21
26
  isBlackListed: { type: Boolean, default: false },
22
27
  createdBy: {
23
28
  type: Schema.Types.ObjectId,
@@ -14,6 +14,11 @@ 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
+ },
17
22
  workspaceId: {
18
23
  type: Schema.Types.ObjectId,
19
24
  ref: "Workspace",
package/models/Order.js CHANGED
@@ -103,6 +103,11 @@ 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
+ },
106
111
  orderType: { type: String, default: "" },
107
112
  barCode: { type: String, default: "" },
108
113
  quantity: { type: Number, default: 0 },
@@ -223,6 +228,7 @@ const markOrUnMarkOrderAsDuplicate = async (doc) => {
223
228
  const phoneRegex = getPhoneRegex(doc?.customerPhone);
224
229
  let orders = await Order.find({
225
230
  workspaceId: doc?.workspaceId,
231
+ websiteId: doc?.websiteId,
226
232
  isDeleted: false,
227
233
  statusType: "pending",
228
234
  customerPhone: phoneRegex,
@@ -20,6 +20,11 @@ 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
+ },
23
28
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
24
29
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
25
30
  },
@@ -31,6 +31,11 @@ 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
+ },
34
39
  },
35
40
  { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
36
41
  );
package/models/Product.js CHANGED
@@ -31,6 +31,11 @@ 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,
34
39
  },
35
40
  storeType: { type: String, default: "LOCAL" },
36
41
  isDeleted: { type: Boolean, default: false },
@@ -22,6 +22,11 @@ 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
+ },
25
30
  height: { type: Number, default: 0 },
26
31
  variantIds: [
27
32
  { type: Schema.Types.ObjectId, ref: "ProductVariant", default: null },
@@ -16,6 +16,11 @@ 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
+ },
19
24
  isDeleted: { type: Boolean, default: false },
20
25
  position: { type: String, default: "1" },
21
26
  },
@@ -11,6 +11,11 @@ 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
+ },
14
19
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
15
20
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
16
21
  isDeleted: { type: Boolean, default: false },
@@ -11,6 +11,11 @@ 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
+ },
14
19
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
15
20
  updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
16
21
  isDeleted: { type: Boolean, default: false },
@@ -17,6 +17,11 @@ 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
+ },
20
25
  webVariantId: { type: Number, default: null },
21
26
  inventoryPolicy: { type: String, default: "deny" }, //deny,continue
22
27
  taxable: { type: Boolean, default: false },
package/models/Tag.js CHANGED
@@ -8,6 +8,11 @@ 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
+ },
11
16
  oldId: { type: String, default: "" },
12
17
  webTagId: { type: Number, default: "" },
13
18
  createdBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
@@ -13,6 +13,11 @@ 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
+ },
16
21
  productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
17
22
  locationId: { type: Schema.Types.ObjectId, ref: "Location", default: null },
18
23
  webVariantLocationId: { type: Number, default: null },
@@ -347,6 +347,8 @@ const workspaceSchema = new mongoose.Schema(
347
347
  type: String,
348
348
  default: "",
349
349
  },
350
+ genericKey: { type: Boolean, default: false },
351
+ tokens: { type: Number, default: 0 },
350
352
  },
351
353
  },
352
354
  { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } }
package/models.js CHANGED
@@ -106,6 +106,7 @@ const WorkflowRun = require("./models/WorkflowRun");
106
106
  const Workflow = require("./models/Workflow");
107
107
  const Workspace = require("./models/Workspace");
108
108
  const Shop = require("./models/Shop");
109
+ const Call = require("./models/Call");
109
110
 
110
111
  module.exports = {
111
112
  Activity,
@@ -216,4 +217,5 @@ module.exports = {
216
217
  Workspace,
217
218
  WorkflowRun,
218
219
  Shop,
220
+ Call,
219
221
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shuttlepro-shared",
3
- "version": "1.4.36",
3
+ "version": "1.4.37",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {