@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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
package/lib/routes/index.js
CHANGED
|
@@ -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,
|