@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 {
|
|
38
|
+
const { threads, total, airtableNameMap } = await fetchConversationData(filter, skip, limit);
|
|
39
39
|
|
|
40
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
62
|
+
logger.info('[fetchConversationData] Completed', { duration: Date.now() - startTime, count: threads.length });
|
|
113
63
|
|
|
114
|
-
const phoneNumbers =
|
|
64
|
+
const phoneNumbers = threads.map(t => t.code).filter(Boolean);
|
|
115
65
|
let airtableNameMap = {};
|
|
116
|
-
let threadNameMap = {};
|
|
117
66
|
if (phoneNumbers.length) {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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 (
|
|
143
|
-
const createConversation = (
|
|
144
|
-
const
|
|
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:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
review:
|
|
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 = (
|
|
116
|
+
const result = (threads || []).map((thread, index) => {
|
|
162
117
|
try {
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|