@peopl-health/nexus 3.9.2 → 3.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/controllers/conversationController.js +13 -7
- package/lib/helpers/templateRecoveryHelper.js +30 -13
- package/lib/helpers/threadHelper.js +1 -1
- package/lib/models/messageModel.js +4 -2
- package/lib/models/threadModel.js +2 -1
- package/lib/providers/OpenAIResponsesProviderTools.js +1 -4
- package/lib/services/conversationService.js +22 -15
- package/lib/storage/MongoStorage.js +6 -1
- package/package.json +1 -1
|
@@ -35,7 +35,7 @@ const getConversationController = async (req, res) => {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const { conversations, total, nameMap
|
|
38
|
+
const { conversations, total, nameMap } = await fetchConversationData(filter, skip, limit);
|
|
39
39
|
|
|
40
40
|
if (!conversations?.length) {
|
|
41
41
|
return res.status(200).json({
|
|
@@ -45,7 +45,7 @@ const getConversationController = async (req, res) => {
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const processedConversations = await processConversations(conversations, nameMap
|
|
48
|
+
const processedConversations = await processConversations(conversations, nameMap);
|
|
49
49
|
const totalPages = Math.ceil(total / limit);
|
|
50
50
|
|
|
51
51
|
res.status(200).json({
|
|
@@ -240,7 +240,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
240
240
|
const phoneNumbers = conversations.map(conv => conv._id).filter(Boolean);
|
|
241
241
|
let airtableNameMap = {};
|
|
242
242
|
let airtablePatientIdMap = {};
|
|
243
|
-
|
|
243
|
+
|
|
244
244
|
if (phoneNumbers.length > 0) {
|
|
245
245
|
try {
|
|
246
246
|
const BATCH_SIZE = 50;
|
|
@@ -276,10 +276,14 @@ const searchConversationsController = async (req, res) => {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
let threadNameMap = {};
|
|
279
|
+
let threadUnreadMap = {};
|
|
279
280
|
let messageNameMap = {};
|
|
280
281
|
if (phoneNumbers.length > 0) {
|
|
281
|
-
const threads = await Thread.find({ code: { $in: phoneNumbers } }).select('code nombre').lean();
|
|
282
|
-
threads.forEach(t => {
|
|
282
|
+
const threads = await Thread.find({ code: { $in: phoneNumbers } }).select('code nombre unreadCount').lean();
|
|
283
|
+
threads.forEach(t => {
|
|
284
|
+
if (t.code && t.nombre) threadNameMap[t.code] = t.nombre;
|
|
285
|
+
if (t.code) threadUnreadMap[t.code] = t.unreadCount || 0;
|
|
286
|
+
});
|
|
283
287
|
|
|
284
288
|
const lastPatientMessages = await Message.aggregate([
|
|
285
289
|
{ $match: { numero: { $in: phoneNumbers }, from_me: false, nombre_whatsapp: { $exists: true, $nin: [null, ''] } } },
|
|
@@ -298,7 +302,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
298
302
|
lastMessage: '',
|
|
299
303
|
lastMessageTime: new Date(),
|
|
300
304
|
messageCount: 0,
|
|
301
|
-
unreadCount: 0,
|
|
305
|
+
unreadCount: threadUnreadMap[conv?._id] || 0,
|
|
302
306
|
isLastMessageMedia: false,
|
|
303
307
|
lastMessageType: null,
|
|
304
308
|
lastMessageFromMe: false
|
|
@@ -331,7 +335,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
331
335
|
lastMessage: conv?.latestMessage?.body || '',
|
|
332
336
|
lastMessageTime: conv?.latestMessage?.createdAt || conv?.latestMessage?.timestamp || new Date(),
|
|
333
337
|
messageCount: conv.messageCount || 0,
|
|
334
|
-
unreadCount: 0,
|
|
338
|
+
unreadCount: threadUnreadMap[conv._id] || 0,
|
|
335
339
|
isLastMessageMedia: isMedia || false,
|
|
336
340
|
lastMessageType: mediaType || null,
|
|
337
341
|
lastMessageFromMe: conv?.latestMessage?.from_me || false
|
|
@@ -516,6 +520,8 @@ const markMessagesAsReadController = async (req, res) => {
|
|
|
516
520
|
if (modifiedCount > 0) {
|
|
517
521
|
updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${phoneNumber}"`, { read: true })
|
|
518
522
|
.catch(err => logger.error('[markMessagesAsRead] Failed to update message_monitor', { phoneNumber, error: err.message }));
|
|
523
|
+
Thread.findOneAndUpdate({ code: phoneNumber }, { $set: { unreadCount: 0 } })
|
|
524
|
+
.catch(err => logger.error('[markMessagesAsRead] Failed to reset thread unreadCount', { phoneNumber, error: err.message }));
|
|
519
525
|
}
|
|
520
526
|
|
|
521
527
|
res.status(200).json({
|
|
@@ -7,7 +7,9 @@ const getMessaging = () => require('../core/NexusMessaging');
|
|
|
7
7
|
|
|
8
8
|
async function handle24HourWindowError(message, messageSid) {
|
|
9
9
|
try {
|
|
10
|
-
if (!message?.body || !message?.numero) return;
|
|
10
|
+
if (!message?.body || !message?.numero || !message?._id) return;
|
|
11
|
+
|
|
12
|
+
const messageDocId = message._id;
|
|
11
13
|
|
|
12
14
|
if (message?.statusInfo?.recoveryTemplateSid || message?.statusInfo?.recoverySentAt) {
|
|
13
15
|
logger.info('[TemplateRecovery] Recovery already completed or in progress', { messageSid });
|
|
@@ -16,7 +18,7 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
16
18
|
|
|
17
19
|
try {
|
|
18
20
|
const claim = await Message.updateOne(
|
|
19
|
-
{
|
|
21
|
+
{ _id: messageDocId, 'statusInfo.recoveryStartedAt': { $exists: false } },
|
|
20
22
|
{ $set: { 'statusInfo.recoveryStartedAt': new Date() } }
|
|
21
23
|
);
|
|
22
24
|
if (!claim.modifiedCount && !claim.nModified) {
|
|
@@ -28,7 +30,7 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
const { requireProvider
|
|
33
|
+
const { requireProvider } = getMessaging();
|
|
32
34
|
const provider = requireProvider();
|
|
33
35
|
if (typeof provider.createTemplate !== 'function') {
|
|
34
36
|
logger.warn('[TemplateRecovery] Provider does not support createTemplate, skipping', { messageSid });
|
|
@@ -44,7 +46,7 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
44
46
|
await provider.submitForApproval(twilioContent.sid, approvalName, 'UTILITY');
|
|
45
47
|
|
|
46
48
|
await Message.updateOne(
|
|
47
|
-
{
|
|
49
|
+
{ _id: messageDocId },
|
|
48
50
|
{ $set: { 'statusInfo.recoveryTemplateSid': twilioContent.sid } }
|
|
49
51
|
);
|
|
50
52
|
|
|
@@ -55,7 +57,7 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
55
57
|
logContext: { messageSid },
|
|
56
58
|
onApproved: async (prov) => {
|
|
57
59
|
const claimSend = await Message.updateOne(
|
|
58
|
-
{
|
|
60
|
+
{ _id: messageDocId, 'statusInfo.recoverySentAt': { $exists: false } },
|
|
59
61
|
{ $set: { 'statusInfo.recoverySentAt': new Date() } }
|
|
60
62
|
);
|
|
61
63
|
if (!claimSend?.modifiedCount && !claimSend?.nModified) {
|
|
@@ -64,17 +66,32 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
try {
|
|
67
|
-
await sendMessage({ code: message.numero, contentSid: twilioContent.sid, variables: {} });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const sendResult = await provider.sendMessage({ code: message.numero, contentSid: twilioContent.sid, variables: {} });
|
|
70
|
+
const newMessageId = sendResult?.messageId;
|
|
71
|
+
if (!newMessageId) throw new Error('Provider did not return a messageId for the recovery send');
|
|
72
|
+
|
|
73
|
+
await Message.updateOne(
|
|
74
|
+
{ _id: messageDocId },
|
|
75
|
+
{ $set: {
|
|
76
|
+
message_id: newMessageId,
|
|
77
|
+
content_sid: twilioContent.sid,
|
|
78
|
+
'statusInfo.previousMessageId': messageSid
|
|
79
|
+
}}
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const dupCleanup = await Message.deleteMany({ message_id: newMessageId, _id: { $ne: messageDocId } });
|
|
83
|
+
if (dupCleanup?.deletedCount) {
|
|
84
|
+
logger.info('[TemplateRecovery] Removed provider-created duplicate(s)', { newMessageId, deletedCount: dupCleanup.deletedCount });
|
|
74
85
|
}
|
|
86
|
+
|
|
87
|
+
logger.info('[TemplateRecovery] Recovered', { previousMessageId: messageSid, newMessageId, templateSid: twilioContent.sid });
|
|
88
|
+
|
|
89
|
+
prov.deleteTemplate(twilioContent.sid).catch(deleteErr =>
|
|
90
|
+
logger.warn('[TemplateRecovery] Failed to delete template after send', { newMessageId, templateSid: twilioContent.sid, error: deleteErr.message })
|
|
91
|
+
);
|
|
75
92
|
} catch (sendErr) {
|
|
76
93
|
await Message.updateOne(
|
|
77
|
-
{
|
|
94
|
+
{ _id: messageDocId },
|
|
78
95
|
{ $unset: { 'statusInfo.recoverySentAt': '' } }
|
|
79
96
|
);
|
|
80
97
|
logger.error('[TemplateRecovery] Error sending approved template', { messageSid, templateSid: twilioContent.sid, error: sendErr.message });
|
|
@@ -52,7 +52,7 @@ const setThreadPromptId = async (code, promptId) => {
|
|
|
52
52
|
const createPlaceholderThread = async (code) => {
|
|
53
53
|
try {
|
|
54
54
|
const existing = await Thread.findOne({ code });
|
|
55
|
-
if (existing) return existing;
|
|
55
|
+
if (existing?.stopped !== undefined) return existing;
|
|
56
56
|
|
|
57
57
|
const nodeEnv = runtimeConfig.get('NODE_ENV');
|
|
58
58
|
const assistantStatus = nodeEnv === 'production' ? 'prod' : nodeEnv === 'development' ? 'dev' : nodeEnv;
|
|
@@ -179,11 +179,12 @@ async function insertMessage(values) {
|
|
|
179
179
|
messageData.tools_executed = [];
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
await Message.findOneAndUpdate(
|
|
182
|
+
const result = await Message.findOneAndUpdate(
|
|
183
183
|
{ message_id: values.message_id, body: values.body },
|
|
184
184
|
{ $setOnInsert: messageData },
|
|
185
|
-
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
185
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true, includeResultMetadata: true }
|
|
186
186
|
);
|
|
187
|
+
const isNew = !result?.lastErrorObject?.updatedExisting;
|
|
187
188
|
|
|
188
189
|
updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${values.numero}"`, {
|
|
189
190
|
...(values.from_me ? { last_message_bot: values.body } : { last_message_patient: values.body, read: false }),
|
|
@@ -191,6 +192,7 @@ async function insertMessage(values) {
|
|
|
191
192
|
}).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
|
|
192
193
|
|
|
193
194
|
logger.info('[MongoStorage] Message inserted or updated successfully');
|
|
195
|
+
return { isNew };
|
|
194
196
|
} catch (err) {
|
|
195
197
|
logger.error('[MongoStorage] Error inserting message', { error: err.message, stack: err.stack });
|
|
196
198
|
throw err;
|
|
@@ -14,7 +14,8 @@ const threadSchema = new mongoose.Schema({
|
|
|
14
14
|
review: { type: Boolean, default: false },
|
|
15
15
|
version: { type: Number, default: null },
|
|
16
16
|
preset_id: { type: String, default: null },
|
|
17
|
-
nextSid: { type: [String], default: [] }
|
|
17
|
+
nextSid: { type: [String], default: [] },
|
|
18
|
+
unreadCount: { type: Number, default: 0 }
|
|
18
19
|
}, { timestamps: true });
|
|
19
20
|
|
|
20
21
|
threadSchema.index({ code: 1, active: 1 });
|
|
@@ -11,10 +11,7 @@ async function executeFunctionCall(assistant, call) {
|
|
|
11
11
|
let result, success = true;
|
|
12
12
|
try {
|
|
13
13
|
if (hasTool(name)) {
|
|
14
|
-
|
|
15
|
-
code: assistant?.thread?.code || ''
|
|
16
|
-
};
|
|
17
|
-
result = await registryExecuteTool(name, args, context);
|
|
14
|
+
result = await registryExecuteTool(name, args, assistant?.thread);
|
|
18
15
|
} else {
|
|
19
16
|
result = await assistant.executeTool(name, args);
|
|
20
17
|
}
|
|
@@ -19,7 +19,10 @@ const BASE_MATCH = { group_id: null };
|
|
|
19
19
|
const UNREAD_MATCH = { from_me: false, $or: [{ read: false }, { read: { $exists: false } }] };
|
|
20
20
|
const PENDING_REVIEW_MATCH = { $or: [{ review: false }, { review: null }] };
|
|
21
21
|
const THREAD_LOOKUP = { $lookup: { from: 'threads', localField: '_id', foreignField: 'code', as: 'threadInfo' } };
|
|
22
|
-
const
|
|
22
|
+
const THREAD_FIELDS = { $addFields: {
|
|
23
|
+
review: { $arrayElemAt: ['$threadInfo.review', 0] },
|
|
24
|
+
unreadCount: { $ifNull: [{ $arrayElemAt: ['$threadInfo.unreadCount', 0] }, 0] }
|
|
25
|
+
} };
|
|
23
26
|
|
|
24
27
|
const toMap = (arr, keyFn, valFn) => arr.reduce((m, item) => {
|
|
25
28
|
const key = keyFn(item);
|
|
@@ -33,8 +36,14 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
33
36
|
triageIds = (triageRecords || []).map(r => r.whatsapp_id).filter(Boolean);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
let unreadCodes = [];
|
|
40
|
+
if (filter === 'unread') {
|
|
41
|
+
const unreadThreads = await Thread.find({ unreadCount: { $gt: 0 } }, { code: 1, _id: 0 }).lean();
|
|
42
|
+
unreadCodes = unreadThreads.map(t => t.code);
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
const filterMap = {
|
|
37
|
-
unread: { ...BASE_MATCH,
|
|
46
|
+
unread: { ...BASE_MATCH, numero: { $in: unreadCodes } },
|
|
38
47
|
recent: { ...BASE_MATCH, createdAt: { $gt: new Date(Date.now() - 86400000) } },
|
|
39
48
|
'no-response': BASE_MATCH,
|
|
40
49
|
'pending-review': BASE_MATCH,
|
|
@@ -53,7 +62,7 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
53
62
|
{ $match: { _id: { $nin: [null, ''] } } },
|
|
54
63
|
...(filter === 'no-response' ? [{ $match: { 'latestMessage.from_me': false } }] : []),
|
|
55
64
|
THREAD_LOOKUP,
|
|
56
|
-
|
|
65
|
+
THREAD_FIELDS,
|
|
57
66
|
{ $project: { threadInfo: 0 } },
|
|
58
67
|
...(filter === 'pending-review' ? [{ $match: PENDING_REVIEW_MATCH }] : []),
|
|
59
68
|
{ $sort: { 'latestMessage.createdAt': -1 } },
|
|
@@ -77,7 +86,7 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
77
86
|
{ $match: BASE_MATCH },
|
|
78
87
|
{ $group: { _id: '$numero' } },
|
|
79
88
|
THREAD_LOOKUP,
|
|
80
|
-
|
|
89
|
+
THREAD_FIELDS,
|
|
81
90
|
{ $match: PENDING_REVIEW_MATCH },
|
|
82
91
|
{ $count: 'total' }
|
|
83
92
|
];
|
|
@@ -85,19 +94,19 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
85
94
|
return [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }];
|
|
86
95
|
};
|
|
87
96
|
|
|
97
|
+
const totalPromise = filter === 'unread'
|
|
98
|
+
? Promise.resolve(unreadCodes.length)
|
|
99
|
+
: Message.aggregate(getTotalPipeline(), { allowDiskUse: true }).then(r => r[0]?.total || 0);
|
|
100
|
+
|
|
88
101
|
const startTime = Date.now();
|
|
89
|
-
const [conversations, contactNames,
|
|
102
|
+
const [conversations, contactNames, total] = await Promise.all([
|
|
90
103
|
Message.aggregate(pipeline, { allowDiskUse: true }),
|
|
91
104
|
Message.aggregate([
|
|
92
105
|
{ $match: { ...BASE_MATCH, from_me: false } },
|
|
93
106
|
{ $sort: { createdAt: -1 } },
|
|
94
107
|
{ $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }
|
|
95
108
|
], { allowDiskUse: true }),
|
|
96
|
-
|
|
97
|
-
{ $match: { ...BASE_MATCH, ...UNREAD_MATCH } },
|
|
98
|
-
{ $group: { _id: '$numero', unreadCount: { $sum: 1 } } }
|
|
99
|
-
], { allowDiskUse: true }),
|
|
100
|
-
Message.aggregate(getTotalPipeline(), { allowDiskUse: true })
|
|
109
|
+
totalPromise
|
|
101
110
|
]);
|
|
102
111
|
|
|
103
112
|
logger.info('[fetchConversationData] Completed', { duration: Date.now() - startTime, count: conversations.length });
|
|
@@ -119,9 +128,7 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
119
128
|
...threadNameMap,
|
|
120
129
|
...airtableNameMap
|
|
121
130
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return { conversations, total: totalResult[0]?.total || 0, nameMap, unreadMap };
|
|
131
|
+
return { conversations, total, nameMap };
|
|
125
132
|
};
|
|
126
133
|
|
|
127
134
|
const getMediaType = (media) => {
|
|
@@ -132,7 +139,7 @@ const getMediaType = (media) => {
|
|
|
132
139
|
return type === 'application' ? 'document' : subtype === 'webp' ? 'sticker' : type;
|
|
133
140
|
};
|
|
134
141
|
|
|
135
|
-
const processConversations = async (conversations, nameMap
|
|
142
|
+
const processConversations = async (conversations, nameMap) => {
|
|
136
143
|
const createConversation = (conv, index) => {
|
|
137
144
|
const msg = conv?.latestMessage;
|
|
138
145
|
const phoneNumber = conv?._id || `error_${index}`;
|
|
@@ -142,7 +149,7 @@ const processConversations = async (conversations, nameMap, unreadMap) => {
|
|
|
142
149
|
lastMessage: msg?.body || '',
|
|
143
150
|
lastMessageTime: msg?.createdAt || msg?.timestamp || new Date(),
|
|
144
151
|
messageCount: conv?.messageCount || 0,
|
|
145
|
-
unreadCount:
|
|
152
|
+
unreadCount: conv?.unreadCount || 0,
|
|
146
153
|
isLastMessageMedia: !!msg?.media,
|
|
147
154
|
lastMessageType: getMediaType(msg?.media),
|
|
148
155
|
lastMessageFromMe: msg?.from_me || false,
|
|
@@ -39,9 +39,14 @@ class MongoStorage {
|
|
|
39
39
|
try {
|
|
40
40
|
const enrichedMessage = await this._enrichTwilioMedia(messageData);
|
|
41
41
|
const values = this.buildMessageValues(enrichedMessage);
|
|
42
|
-
await insertMessage(values);
|
|
42
|
+
const { isNew } = await insertMessage(values);
|
|
43
43
|
logger.info('[MongoStorage] Message stored');
|
|
44
44
|
|
|
45
|
+
if (isNew && !values.from_me) {
|
|
46
|
+
Thread.findOneAndUpdate({ code: values.numero }, { $inc: { unreadCount: 1 } }, { upsert: true })
|
|
47
|
+
.catch(err => logger.warn('[MongoStorage] Failed to increment thread unread_count', { error: err.message }));
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
const chatId = messageData.from;
|
|
46
51
|
const messageId = messageData.id || messageData.messageId;
|
|
47
52
|
|