@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.
@@ -35,7 +35,7 @@ const getConversationController = async (req, res) => {
35
35
  });
36
36
  }
37
37
 
38
- const { conversations, total, nameMap, unreadMap } = await fetchConversationData(filter, skip, limit);
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, unreadMap);
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 => { if (t.code && t.nombre) threadNameMap[t.code] = t.nombre; });
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
- { message_id: messageSid, 'statusInfo.recoveryStartedAt': { $exists: false } },
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, sendMessage } = getMessaging();
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
- { message_id: messageSid },
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
- { message_id: messageSid, 'statusInfo.recoverySentAt': { $exists: false } },
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
- logger.info('[TemplateRecovery] Template sent', { messageSid, templateSid: twilioContent.sid });
69
- try {
70
- await prov.deleteTemplate(twilioContent.sid);
71
- logger.info('[TemplateRecovery] Template deleted', { messageSid, templateSid: twilioContent.sid });
72
- } catch (deleteErr) {
73
- logger.warn('[TemplateRecovery] Failed to delete template after send', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
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
- { message_id: messageSid },
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
- const context = {
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 REVIEW_FIELD = { $addFields: { review: { $arrayElemAt: ['$threadInfo.review', 0] } } };
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, ...UNREAD_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
- REVIEW_FIELD,
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
- REVIEW_FIELD,
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, unreadCounts, totalResult] = await Promise.all([
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
- Message.aggregate([
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
- const unreadMap = toMap(unreadCounts, i => i?._id, i => i.unreadCount || 0);
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, unreadMap) => {
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: unreadMap[phoneNumber] || 0,
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.9.2",
3
+ "version": "3.9.5",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",