@peopl-health/nexus 3.9.9 → 3.10.0

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,9 +35,9 @@ const getConversationController = async (req, res) => {
35
35
  });
36
36
  }
37
37
 
38
- const { conversations, total, nameMap } = await fetchConversationData(filter, skip, limit);
38
+ const { threads, total, airtableNameMap } = await fetchConversationData(filter, skip, limit);
39
39
 
40
- if (!conversations?.length) {
40
+ if (!threads?.length) {
41
41
  return res.status(200).json({
42
42
  success: true,
43
43
  conversations: [],
@@ -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(threads, airtableNameMap);
49
49
  const totalPages = Math.ceil(total / limit);
50
50
 
51
51
  res.status(200).json({
@@ -9,6 +9,10 @@ const { getClinicalContext } = require('../helpers/patientInformationHelper');
9
9
 
10
10
  const { updateRecordByFilter } = require('../services/airtableService');
11
11
 
12
+ const { Thread } = require('./threadModel');
13
+
14
+ const INTERNAL_ORIGINS = new Set(['system', 'instruction']);
15
+
12
16
  const messageSchema = new mongoose.Schema({
13
17
  raw: { type: Object, default: null },
14
18
  body: { type: String, required: true },
@@ -139,6 +143,25 @@ async function insertMessage(values) {
139
143
  );
140
144
  const isNew = !result?.lastErrorObject?.updatedExisting;
141
145
 
146
+ if (isNew && values.numero && !INTERNAL_ORIGINS.has(values.origin)) {
147
+ Thread.findOneAndUpdate(
148
+ { code: values.numero },
149
+ {
150
+ $set: {
151
+ lastMessageAt: new Date(),
152
+ lastMessageBody: values.body || '',
153
+ lastMessageFromMe: !!values.from_me,
154
+ lastMessageMedia: values.media || null
155
+ },
156
+ $inc: {
157
+ messageCount: 1,
158
+ ...(!values.from_me ? { unreadCount: 1 } : {})
159
+ }
160
+ },
161
+ { upsert: true, setDefaultsOnInsert: true }
162
+ ).catch(err => logger.error('[MongoStorage] Failed to denormalize thread', { numero: values.numero, error: err.message }));
163
+ }
164
+
142
165
  updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${values.numero}"`, {
143
166
  ...(values.from_me ? { last_message_bot: values.body } : { last_message_patient: values.body, read: false }),
144
167
  ...(values.from_me ? { last_message_bot_time: values.timestamp } : { last_message_patient_time: values.timestamp })
@@ -15,12 +15,18 @@ const threadSchema = new mongoose.Schema({
15
15
  version: { type: Number, default: null },
16
16
  preset_id: { type: String, default: null },
17
17
  nextSid: { type: [String], default: [] },
18
- unreadCount: { type: Number, default: 0 }
18
+ unreadCount: { type: Number, default: 0 },
19
+ messageCount: { type: Number, default: 0 },
20
+ lastMessageAt: { type: Date, default: null },
21
+ lastMessageBody: { type: String, default: null },
22
+ lastMessageFromMe: { type: Boolean, default: false },
23
+ lastMessageMedia: { type: Object, default: null }
19
24
  }, { timestamps: true });
20
25
 
21
26
  threadSchema.index({ code: 1, active: 1 });
22
27
  threadSchema.index({ thread_id: 1 });
23
28
  threadSchema.index({ conversation_id: 1 });
29
+ threadSchema.index({ lastMessageAt: -1 }, { name: 'last_message_at_idx', sparse: true });
24
30
 
25
31
  threadSchema.methods.getConversationId = function() {
26
32
  return this.conversation_id;
@@ -3,7 +3,6 @@ const { Historial_Clinico_ID, Symptoms_ID } = require('../config/airtableConfig'
3
3
  const { logger } = require('../utils/logger');
4
4
  const { parseDate } = require('../utils/dateUtils');
5
5
 
6
- const { Message } = require('../models/messageModel');
7
6
  const { Thread } = require('../models/threadModel');
8
7
  const TemplateModel = require('../models/templateModel');
9
8
 
@@ -15,20 +14,30 @@ const { sendMessage, requireProvider } = require('../core/NexusMessaging');
15
14
 
16
15
  const { Template } = require('../templates/templateStructure');
17
16
 
18
- const BASE_MATCH = { group_id: null };
19
- const UNREAD_MATCH = { from_me: false, $or: [{ read: false }, { read: { $exists: false } }] };
20
- const PENDING_REVIEW_MATCH = { $or: [{ review: false }, { review: null }] };
21
- const THREAD_LOOKUP = { $lookup: { from: 'threads', localField: '_id', foreignField: 'code', as: 'threadInfo' } };
22
- const THREAD_FIELDS = { $addFields: {
23
- review: { $arrayElemAt: ['$threadInfo.review', 0] },
24
- unreadCount: { $ifNull: [{ $arrayElemAt: ['$threadInfo.unreadCount', 0] }, 0] }
25
- } };
26
-
27
17
  const toMap = (arr, keyFn, valFn) => arr.reduce((m, item) => {
28
18
  const key = keyFn(item);
29
19
  return key ? { ...m, [key]: valFn(item) } : m;
30
20
  }, {});
31
21
 
22
+ const buildThreadFilter = (filter, { triageIds }) => {
23
+ const base = { lastMessageAt: { $ne: null } };
24
+ switch (filter) {
25
+ case 'unread':
26
+ return { ...base, unreadCount: { $gt: 0 } };
27
+ case 'pending-review':
28
+ return { ...base, $or: [{ review: false }, { review: null }] };
29
+ case 'recent':
30
+ return { ...base, lastMessageAt: { $gt: new Date(Date.now() - 86400000) } };
31
+ case 'no-response':
32
+ return { ...base, lastMessageFromMe: false };
33
+ case 'triage-last-month':
34
+ return { ...base, code: { $in: triageIds } };
35
+ case 'all':
36
+ default:
37
+ return base;
38
+ }
39
+ };
40
+
32
41
  const fetchConversationData = async (filter, skip, limit) => {
33
42
  let triageIds = [];
34
43
  if (filter === 'triage-last-month') {
@@ -36,99 +45,34 @@ const fetchConversationData = async (filter, skip, limit) => {
36
45
  triageIds = (triageRecords || []).map(r => r.whatsapp_id).filter(Boolean);
37
46
  }
38
47
 
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
-
45
- const filterMap = {
46
- unread: { ...BASE_MATCH, numero: { $in: unreadCodes } },
47
- recent: { ...BASE_MATCH, createdAt: { $gt: new Date(Date.now() - 86400000) } },
48
- 'no-response': BASE_MATCH,
49
- 'pending-review': BASE_MATCH,
50
- 'triage-last-month': { ...BASE_MATCH, numero: { $in: triageIds } },
51
- all: BASE_MATCH
52
- };
53
- const filterConditions = filterMap[filter] || BASE_MATCH;
48
+ const threadFilter = buildThreadFilter(filter, { triageIds });
54
49
 
55
50
  logger.info('[fetchConversationData] Applying filter', { filter });
56
-
57
- const pipeline = [
58
- { $match: filterConditions },
59
- { $project: { numero: 1, body: 1, createdAt: 1, timestamp: 1, media: 1, nombre_whatsapp: 1, from_me: 1 } },
60
- { $sort: { createdAt: 1, timestamp: 1 } },
61
- { $group: { _id: '$numero', latestMessage: { $last: '$$ROOT' }, messageCount: { $sum: 1 } } },
62
- { $match: { _id: { $nin: [null, ''] } } },
63
- ...(filter === 'no-response' ? [{ $match: { 'latestMessage.from_me': false } }] : []),
64
- THREAD_LOOKUP,
65
- THREAD_FIELDS,
66
- { $project: { threadInfo: 0 } },
67
- ...(filter === 'pending-review' ? [{ $match: PENDING_REVIEW_MATCH }] : []),
68
- { $sort: { 'latestMessage.createdAt': -1 } },
69
- { $skip: skip },
70
- { $limit: limit }
71
- ];
72
-
73
- const getTotalPipeline = () => {
74
- if (filter === 'no-response') {
75
- return [
76
- { $match: BASE_MATCH },
77
- { $project: { numero: 1, from_me: 1, createdAt: 1 } },
78
- { $sort: { createdAt: -1 } },
79
- { $group: { _id: '$numero', latestMessage: { $first: '$$ROOT' } } },
80
- { $match: { 'latestMessage.from_me': false } },
81
- { $count: 'total' }
82
- ];
83
- }
84
- if (filter === 'pending-review') {
85
- return [
86
- { $match: BASE_MATCH },
87
- { $group: { _id: '$numero' } },
88
- THREAD_LOOKUP,
89
- THREAD_FIELDS,
90
- { $match: PENDING_REVIEW_MATCH },
91
- { $count: 'total' }
92
- ];
93
- }
94
- return [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }];
95
- };
96
-
97
- const totalPromise = filter === 'unread'
98
- ? Promise.resolve(unreadCodes.length)
99
- : Message.aggregate(getTotalPipeline(), { allowDiskUse: true }).then(r => r[0]?.total || 0);
100
-
101
51
  const startTime = Date.now();
102
- const [conversations, contactNames, total] = await Promise.all([
103
- Message.aggregate(pipeline, { allowDiskUse: true }),
104
- Message.aggregate([
105
- { $match: { ...BASE_MATCH, from_me: false } },
106
- { $sort: { createdAt: -1 } },
107
- { $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }
108
- ], { allowDiskUse: true }),
109
- totalPromise
52
+
53
+ const [threads, total] = await Promise.all([
54
+ Thread.find(threadFilter)
55
+ .sort({ lastMessageAt: -1 })
56
+ .skip(skip)
57
+ .limit(limit)
58
+ .lean(),
59
+ Thread.countDocuments(threadFilter)
110
60
  ]);
111
61
 
112
- logger.info('[fetchConversationData] Completed', { duration: Date.now() - startTime, count: conversations.length });
62
+ logger.info('[fetchConversationData] Completed', { duration: Date.now() - startTime, count: threads.length });
113
63
 
114
- const phoneNumbers = conversations.map(c => c._id).filter(Boolean);
64
+ const phoneNumbers = threads.map(t => t.code).filter(Boolean);
115
65
  let airtableNameMap = {};
116
- let threadNameMap = {};
117
66
  if (phoneNumbers.length) {
118
- const formula = `OR(${phoneNumbers.map(p => `{whatsapp_id} = "${p}"`).join(', ')})`;
119
- const records = await getRecordByFilter(Historial_Clinico_ID, 'estado_general', formula);
120
- airtableNameMap = toMap((records || []).filter(r => r.name), r => r.whatsapp_id, r => r.name);
121
-
122
- const threads = await Thread.find({ code: { $in: phoneNumbers } }).select('code nombre').lean();
123
- threadNameMap = toMap((threads || []).filter(t => t.nombre), t => t.code, t => t.nombre);
67
+ const missingNombre = threads.filter(t => !t.nombre).map(t => t.code);
68
+ if (missingNombre.length) {
69
+ const formula = `OR(${missingNombre.map(p => `{whatsapp_id} = "${p}"`).join(', ')})`;
70
+ const records = await getRecordByFilter(Historial_Clinico_ID, 'estado_general', formula);
71
+ airtableNameMap = toMap((records || []).filter(r => r.name), r => r.whatsapp_id, r => r.name);
72
+ }
124
73
  }
125
74
 
126
- const nameMap = {
127
- ...toMap(contactNames, c => c?._id ? c._id : null, c => c.name || 'Desconocido'),
128
- ...threadNameMap,
129
- ...airtableNameMap
130
- };
131
- return { conversations, total, nameMap };
75
+ return { threads, total, airtableNameMap };
132
76
  };
133
77
 
134
78
  const getMediaType = (media) => {
@@ -139,29 +83,39 @@ const getMediaType = (media) => {
139
83
  return type === 'application' ? 'document' : subtype === 'webp' ? 'sticker' : type;
140
84
  };
141
85
 
142
- const processConversations = async (conversations, nameMap) => {
143
- const createConversation = (conv, index) => {
144
- const msg = conv?.latestMessage;
145
- const phoneNumber = conv?._id || `error_${index}`;
86
+ const processConversations = async (threads, airtableNameMap) => {
87
+ const createConversation = (thread, index) => {
88
+ const phoneNumber = thread?.code || `error_${index}`;
146
89
  return {
147
90
  phoneNumber,
148
- name: nameMap[phoneNumber],
149
- lastMessage: msg?.body || '',
150
- lastMessageTime: msg?.createdAt || msg?.timestamp || new Date(),
151
- messageCount: conv?.messageCount || 0,
152
- unreadCount: conv?.unreadCount || 0,
153
- isLastMessageMedia: !!msg?.media,
154
- lastMessageType: getMediaType(msg?.media),
155
- lastMessageFromMe: msg?.from_me || false,
156
- review: conv?.review || false
91
+ name: thread?.nombre || airtableNameMap[phoneNumber] || 'Desconocido',
92
+ patientId: thread?.patient_id || null,
93
+ assistantId: thread?.assistant_id || null,
94
+ presetId: thread?.preset_id || null,
95
+ promptId: thread?.prompt_id || null,
96
+ version: thread?.version || null,
97
+ active: thread?.active ?? true,
98
+ stopped: thread?.stopped ?? false,
99
+ review: thread?.review ?? false,
100
+ unreadCount: thread?.unreadCount || 0,
101
+ messageCount: thread?.messageCount || 0,
102
+ nextSid: thread?.nextSid || [],
103
+ conversationId: thread?.conversation_id || null,
104
+ threadId: thread?.thread_id || null,
105
+ threadCreatedAt: thread?.createdAt || null,
106
+ threadUpdatedAt: thread?.updatedAt || null,
107
+ lastMessage: thread?.lastMessageBody || '',
108
+ lastMessageTime: thread?.lastMessageAt || null,
109
+ lastMessageFromMe: thread?.lastMessageFromMe || false,
110
+ isLastMessageMedia: !!thread?.lastMessageMedia,
111
+ lastMessageType: getMediaType(thread?.lastMessageMedia)
157
112
  };
158
113
  };
159
114
 
160
115
  try {
161
- const result = (conversations || []).map((conv, index) => {
116
+ const result = (threads || []).map((thread, index) => {
162
117
  try {
163
- if (!conv?.latestMessage) logger.warn('[processConversations] Missing latestMessage', { index, id: conv?._id });
164
- return createConversation(conv, index);
118
+ return createConversation(thread, index);
165
119
  } catch (error) {
166
120
  logger.error('[processConversations] Error processing', { index, error: error.message });
167
121
  return null;
@@ -37,14 +37,9 @@ class MongoStorage {
37
37
  async saveMessage(messageData) {
38
38
  try {
39
39
  const values = this.buildMessageValues(messageData);
40
- const { isNew } = await insertMessage(values);
40
+ await insertMessage(values);
41
41
  logger.info('[MongoStorage] Message stored');
42
42
 
43
- if (isNew && !values.from_me) {
44
- Thread.findOneAndUpdate({ code: values.numero }, { $inc: { unreadCount: 1 } }, { upsert: true })
45
- .catch(err => logger.warn('[MongoStorage] Failed to increment thread unread_count', { error: err.message }));
46
- }
47
-
48
43
  const chatId = messageData.from;
49
44
  const messageId = messageData.messageId;
50
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.9.9",
3
+ "version": "3.10.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",