@peopl-health/nexus 2.5.8 → 2.5.10

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.
@@ -105,15 +105,52 @@ class TwilioProvider extends MessageProvider {
105
105
  }
106
106
  }
107
107
 
108
- // Validate message has content
109
108
  if (!messageParams.body && !messageParams.mediaUrl && !messageParams.contentSid) {
110
109
  throw new Error('Message must have body, media URL, or content SID');
111
110
  }
112
111
 
113
- logger.info('[TwilioProvider] Sending message', messageParams);
114
-
115
- try {
116
- const result = await this.twilioClient.messages.create(messageParams);
112
+ let result;
113
+ const chunks = messageParams.body && messageParams.body.length > 1600 && !messageParams.mediaUrl && !messageParams.contentSid
114
+ ? this.splitMessageAtWordBoundaries(messageParams.body)
115
+ : null;
116
+
117
+ if (chunks) {
118
+ for (let i = 0; i < chunks.length; i++) {
119
+ const chunkParams = { ...messageParams, body: chunks[i] };
120
+ result = await this.twilioClient.messages.create(chunkParams);
121
+ if (this.messageStorage && typeof this.messageStorage.saveMessage === 'function') {
122
+ try {
123
+ await this.messageStorage.saveMessage({
124
+ ...messageData,
125
+ body: chunks[i],
126
+ code: formattedCode,
127
+ from: formattedFrom,
128
+ messageId: result.sid,
129
+ provider: 'twilio',
130
+ timestamp: new Date(),
131
+ fromMe: true,
132
+ processed: messageData.processed !== undefined ? messageData.processed : false,
133
+ statusInfo: {
134
+ status: result.status ? result.status.toLowerCase() : null,
135
+ updatedAt: result.dateCreated || new Date()
136
+ }
137
+ });
138
+ logger.info('[TwilioProvider] Message chunk persisted', { messageId: result.sid, chunk: i + 1, total: chunks.length });
139
+ } catch (storageError) {
140
+ logger.error('TwilioProvider storage failed:', storageError);
141
+ }
142
+ }
143
+ if (i < chunks.length - 1) {
144
+ await new Promise(resolve => setTimeout(resolve, 100));
145
+ }
146
+ }
147
+ } else {
148
+ logger.info('[TwilioProvider] Sending message', messageParams);
149
+ try {
150
+ result = await this.twilioClient.messages.create(messageParams);
151
+ } catch (error) {
152
+ throw new Error(`Twilio send failed: ${error.message}`);
153
+ }
117
154
  if (this.messageStorage && typeof this.messageStorage.saveMessage === 'function') {
118
155
  try {
119
156
  await this.messageStorage.saveMessage({
@@ -135,17 +172,15 @@ class TwilioProvider extends MessageProvider {
135
172
  logger.error('TwilioProvider storage failed:', storageError);
136
173
  }
137
174
  }
138
-
139
- return {
140
- success: true,
141
- messageId: result.sid,
142
- provider: 'twilio',
143
- status: result.status,
144
- result
145
- };
146
- } catch (error) {
147
- throw new Error(`Twilio send failed: ${error.message}`);
148
175
  }
176
+
177
+ return {
178
+ success: true,
179
+ messageId: result.sid,
180
+ provider: 'twilio',
181
+ status: result.status,
182
+ result
183
+ };
149
184
  }
150
185
 
151
186
  async sendTypingIndicator(messageId) {
@@ -394,6 +429,46 @@ class TwilioProvider extends MessageProvider {
394
429
  return Math.max(0, targetTime.getTime() - now.getTime());
395
430
  }
396
431
 
432
+ /**
433
+ * Split a message into chunks at sentence boundaries, respecting Twilio's character limit
434
+ * @param {string} text - The message text to split
435
+ * @param {number} maxLength - Maximum length per chunk (default: 1600)
436
+ * @returns {Array<string>} Array of message chunks
437
+ */
438
+ splitMessageAtWordBoundaries(text, maxLength = 1600) {
439
+ if (!text || text.length <= maxLength) return [text];
440
+ const chunks = [];
441
+ let remaining = text;
442
+ while (remaining.length > maxLength) {
443
+ let splitIndex = -1;
444
+ const searchStart = Math.max(0, maxLength - 500); // Look back up to 500 chars
445
+ const searchArea = remaining.substring(searchStart, maxLength + 1);
446
+
447
+ const regex = /[.!?]\s/g;
448
+ let match;
449
+ let lastMatchIndex = -1;
450
+
451
+ while ((match = regex.exec(searchArea)) !== null) {
452
+ const absoluteIndex = searchStart + match.index;
453
+ if (absoluteIndex <= maxLength) {
454
+ lastMatchIndex = absoluteIndex + match[0].length;
455
+ }
456
+ }
457
+
458
+ if (lastMatchIndex > 0) {
459
+ splitIndex = lastMatchIndex;
460
+ } else {
461
+ splitIndex = remaining.lastIndexOf(' ', maxLength);
462
+ }
463
+
464
+ const chunk = remaining.substring(0, splitIndex === -1 ? maxLength : splitIndex).trim();
465
+ if (chunk) chunks.push(chunk);
466
+ remaining = remaining.substring(splitIndex === -1 ? maxLength : splitIndex).trim();
467
+ }
468
+ if (remaining) chunks.push(remaining);
469
+ return chunks;
470
+ }
471
+
397
472
  /**
398
473
  * List templates from Twilio Content API
399
474
  * @param {Object} options - Query options
@@ -0,0 +1,151 @@
1
+ const { Thread } = require('../models/threadModel');
2
+ const { logger } = require('../utils/logger');
3
+
4
+ /**
5
+ * Update review status for a specific thread by code
6
+ */
7
+ const updateThreadReviewStatus = async (req, res) => {
8
+ try {
9
+ const { code } = req.params;
10
+ const { review } = req.body;
11
+
12
+ if (typeof review !== 'boolean') {
13
+ return res.status(400).json({
14
+ success: false,
15
+ message: 'Review status must be a boolean value'
16
+ });
17
+ }
18
+
19
+ const thread = await Thread.findOneAndUpdate(
20
+ { code },
21
+ { review },
22
+ { new: true }
23
+ );
24
+
25
+ if (!thread) {
26
+ return res.status(404).json({
27
+ success: false,
28
+ message: 'Thread not found'
29
+ });
30
+ }
31
+
32
+ logger.info('[updateThreadReviewStatus] Thread review status updated', {
33
+ code: code.substring(0, 3) + '***' + code.slice(-4),
34
+ review
35
+ });
36
+
37
+ res.json({
38
+ success: true,
39
+ message: 'Thread review status updated successfully',
40
+ thread: {
41
+ code: thread.code,
42
+ review: thread.review,
43
+ updatedAt: thread.updatedAt
44
+ }
45
+ });
46
+ } catch (error) {
47
+ logger.error('[updateThreadReviewStatus] Error updating thread review status', { error });
48
+ res.status(500).json({
49
+ success: false,
50
+ message: 'Internal server error'
51
+ });
52
+ }
53
+ };
54
+
55
+ /**
56
+ * Update review status for all threads at once
57
+ */
58
+ const updateAllThreadsReviewStatus = async (req, res) => {
59
+ try {
60
+ const { review } = req.body;
61
+
62
+ if (typeof review !== 'boolean') {
63
+ return res.status(400).json({
64
+ success: false,
65
+ message: 'Review status must be a boolean value'
66
+ });
67
+ }
68
+
69
+ const result = await Thread.updateMany(
70
+ {},
71
+ { review }
72
+ );
73
+
74
+ logger.info('[updateAllThreadsReviewStatus] All threads review status updated', {
75
+ review,
76
+ modifiedCount: result.modifiedCount
77
+ });
78
+
79
+ res.json({
80
+ success: true,
81
+ message: `Successfully updated review status for ${result.modifiedCount} threads`,
82
+ modifiedCount: result.modifiedCount,
83
+ review
84
+ });
85
+ } catch (error) {
86
+ logger.error('[updateAllThreadsReviewStatus] Error updating all threads review status', { error });
87
+ res.status(500).json({
88
+ success: false,
89
+ message: 'Internal server error'
90
+ });
91
+ }
92
+ };
93
+
94
+ /**
95
+ * Get threads by review status
96
+ */
97
+ const getThreadsByReviewStatus = async (req, res) => {
98
+ try {
99
+ const { review } = req.query;
100
+ const { page = 1, limit = 20 } = req.query;
101
+
102
+ const filter = {};
103
+ if (review !== undefined) {
104
+ filter.review = review === 'true';
105
+ }
106
+
107
+ const skip = (parseInt(page) - 1) * parseInt(limit);
108
+
109
+ const threads = await Thread.find(filter)
110
+ .select('code review active stopped createdAt updatedAt')
111
+ .sort({ updatedAt: -1 })
112
+ .skip(skip)
113
+ .limit(parseInt(limit));
114
+
115
+ const total = await Thread.countDocuments(filter);
116
+
117
+ logger.info('[getThreadsByReviewStatus] Retrieved threads by review status', {
118
+ filter,
119
+ count: threads.length,
120
+ total,
121
+ page: parseInt(page),
122
+ limit: parseInt(limit)
123
+ });
124
+
125
+ res.json({
126
+ success: true,
127
+ threads: threads.map(thread => ({
128
+ ...thread.toObject(),
129
+ code: thread.code.substring(0, 3) + '***' + thread.code.slice(-4)
130
+ })),
131
+ pagination: {
132
+ page: parseInt(page),
133
+ limit: parseInt(limit),
134
+ total,
135
+ totalPages: Math.ceil(total / parseInt(limit))
136
+ }
137
+ });
138
+ } catch (error) {
139
+ logger.error('[getThreadsByReviewStatus] Error retrieving threads by review status', { error });
140
+ res.status(500).json({
141
+ success: false,
142
+ message: 'Internal server error'
143
+ });
144
+ }
145
+ };
146
+
147
+ module.exports = {
148
+ updateThreadReviewStatus,
149
+ updateAllThreadsReviewStatus,
150
+ getThreadsByReviewStatus
151
+ };
@@ -1,5 +1,6 @@
1
1
  const { airtable, getBase } = require('../config/airtableConfig');
2
2
  const { Message } = require('../models/messageModel');
3
+ const { Thread } = require('../models/threadModel');
3
4
  const { addMsgAssistant, replyAssistant } = require('../services/assistantService');
4
5
  const { createProvider } = require('../adapters/registry');
5
6
  const runtimeConfig = require('../config/runtimeConfig');
@@ -358,6 +359,19 @@ class NexusMessaging {
358
359
  // Ensure thread exists in background for new numbers
359
360
  if (chatId) {
360
361
  ensureThreadExists(chatId);
362
+
363
+ // Reset review status to false when new message arrives
364
+ try {
365
+ await Thread.updateOne(
366
+ { code: chatId },
367
+ { review: false }
368
+ );
369
+ } catch (error) {
370
+ logger.error('[processIncomingMessage] Failed to reset thread review status', {
371
+ code: chatId.substring(0, 3) + '***' + chatId.slice(-4),
372
+ error
373
+ });
374
+ }
361
375
  }
362
376
 
363
377
  if (chatId && hasPreprocessingHandler()) {
@@ -11,6 +11,7 @@ const threadSchema = new mongoose.Schema({
11
11
  nombre: { type: String, default: null },
12
12
  active: { type: Boolean, default: true },
13
13
  stopped: { type: Boolean, default: false },
14
+ review: { type: Boolean, default: false },
14
15
  nextSid: { type: [String], default: [] }
15
16
  }, { timestamps: true });
16
17
 
@@ -66,6 +66,12 @@ const templateRouteDefinitions = {
66
66
  'DELETE /:id': 'deleteTemplate'
67
67
  };
68
68
 
69
+ const threadRouteDefinitions = {
70
+ 'PUT /review/:code': 'updateThreadReviewStatus',
71
+ 'PUT /review/all': 'updateAllThreadsReviewStatus',
72
+ 'GET /review': 'getThreadsByReviewStatus'
73
+ };
74
+
69
75
  // Helper function to create Express router from route definitions
70
76
  const createRouter = (routeDefinitions, controllers) => {
71
77
  const router = express.Router();
@@ -93,6 +99,7 @@ const patientController = require('../controllers/patientController');
93
99
  const qualityMessageController = require('../controllers/qualityMessageController');
94
100
  const templateController = require('../controllers/templateController');
95
101
  const templateFlowController = require('../controllers/templateFlowController');
102
+ const threadController = require('../controllers/threadController');
96
103
  const uploadController = require('../controllers/uploadController');
97
104
 
98
105
  // Built-in controllers mapping
@@ -152,7 +159,12 @@ const builtInControllers = {
152
159
  deleteFlow: templateFlowController.deleteFlow,
153
160
  submitForApproval: templateController.submitForApproval,
154
161
  checkApprovalStatus: templateController.checkApprovalStatus,
155
- deleteTemplate: templateController.deleteTemplate
162
+ deleteTemplate: templateController.deleteTemplate,
163
+
164
+ // Thread controllers
165
+ updateThreadReviewStatus: threadController.updateThreadReviewStatus,
166
+ updateAllThreadsReviewStatus: threadController.updateAllThreadsReviewStatus,
167
+ getThreadsByReviewStatus: threadController.getThreadsByReviewStatus
156
168
  };
157
169
 
158
170
  // Helper function to setup all default routes using built-in controllers
@@ -164,6 +176,7 @@ const setupDefaultRoutes = (app) => {
164
176
  app.use('/api/message', createRouter(messageRouteDefinitions, builtInControllers));
165
177
  app.use('/api/patient', createRouter(patientRouteDefinitions, builtInControllers));
166
178
  app.use('/api/template', createRouter(templateRouteDefinitions, builtInControllers));
179
+ app.use('/api/thread', createRouter(threadRouteDefinitions, builtInControllers));
167
180
  };
168
181
 
169
182
  module.exports = {
@@ -175,6 +188,7 @@ module.exports = {
175
188
  messageRoutes: messageRouteDefinitions,
176
189
  patientRoutes: patientRouteDefinitions,
177
190
  templateRoutes: templateRouteDefinitions,
191
+ threadRoutes: threadRouteDefinitions,
178
192
 
179
193
  // Helper functions
180
194
  createRouter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "2.5.8",
3
+ "version": "2.5.10",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",