@peopl-health/nexus 1.1.1 → 1.1.4
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/README.md +2 -46
- package/examples/basic-usage.js +65 -49
- package/examples/consumer-server.js +10 -9
- package/lib/config/airtableConfig.js +45 -0
- package/lib/{utils → config}/mongoAuthConfig.js +3 -13
- package/lib/controllers/assistantController.js +8 -22
- package/lib/controllers/conversationController.js +1 -1
- package/lib/controllers/messageController.js +24 -12
- package/lib/controllers/templateController.js +28 -8
- package/lib/core/NexusMessaging.js +97 -0
- package/lib/routes/index.js +58 -7
- package/lib/services/airtableService.js +82 -0
- package/lib/services/appointmentService.js +55 -0
- package/lib/services/assistantService.js +296 -0
- package/lib/services/conversationService.js +274 -0
- package/lib/services/whatsappService.js +23 -0
- package/lib/utils/index.js +1 -25
- package/package.json +4 -2
- package/lib/utils/twilioHelper.js +0 -75
- package/lib/utils/whatsappHelper.js +0 -60
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { airtable } = require('../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
async function addRecord(baseID, tableName, fields) {
|
|
5
|
+
try {
|
|
6
|
+
const base = airtable.base(baseID);
|
|
7
|
+
const record = await base(tableName).create(fields);
|
|
8
|
+
console.log('Record added at', tableName);
|
|
9
|
+
return record;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error('Error adding record:', error);
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async function getRecords(baseID, tableName) {
|
|
18
|
+
try {
|
|
19
|
+
const records = [];
|
|
20
|
+
const base = airtable.base(baseID);
|
|
21
|
+
await base(tableName).select({
|
|
22
|
+
maxRecords: 3
|
|
23
|
+
}).eachPage((pageRecords, fetchNextPage) => {
|
|
24
|
+
pageRecords.forEach(record => {
|
|
25
|
+
records.push(record.fields);
|
|
26
|
+
});
|
|
27
|
+
fetchNextPage();
|
|
28
|
+
});
|
|
29
|
+
return records;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('Error fetching records:', error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view') {
|
|
37
|
+
try {
|
|
38
|
+
const records = [];
|
|
39
|
+
const base = airtable.base(baseID);
|
|
40
|
+
await base(tableName).select({
|
|
41
|
+
filterByFormula: `${filter}`,
|
|
42
|
+
view: view
|
|
43
|
+
}).eachPage((pageRecords, fetchNextPage) => {
|
|
44
|
+
pageRecords.forEach(record => {
|
|
45
|
+
records.push(record.fields);
|
|
46
|
+
});
|
|
47
|
+
fetchNextPage();
|
|
48
|
+
});
|
|
49
|
+
return records;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Error fetching records by ${filter}:`, error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
|
|
57
|
+
try {
|
|
58
|
+
const base = airtable.base(baseID);
|
|
59
|
+
const updatedRecords = [];
|
|
60
|
+
|
|
61
|
+
await base(tableName).select({
|
|
62
|
+
filterByFormula: `${filter}`
|
|
63
|
+
}).eachPage(async (pageRecords, fetchNextPage) => {
|
|
64
|
+
for (const record of pageRecords) {
|
|
65
|
+
const updatedRecord = await base(tableName).update(record.id, updateFields);
|
|
66
|
+
updatedRecords.push(updatedRecord);
|
|
67
|
+
}
|
|
68
|
+
fetchNextPage();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return updatedRecords;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Error updating records by ${filter}:`, error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
addRecord,
|
|
79
|
+
getRecords,
|
|
80
|
+
getRecordByFilter,
|
|
81
|
+
updateRecordByFilter
|
|
82
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { Calendar_ID } = require('../config/airtableConfig.js');
|
|
2
|
+
|
|
3
|
+
const { ISO_DATE, parseStartTime, addDays } = require('../utils/dateUtils.js');
|
|
4
|
+
const { dateAndTimeFromStart } = require('../utils/dateUtils.js');
|
|
5
|
+
|
|
6
|
+
const { getRecordByFilter, updateRecordByFilter } = require('../services/airtableService.js');
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const getNextAppointmentForPatient = async (name, after = new Date()) => {
|
|
10
|
+
const filter = `AND(patient_name = "${name}", start_time >= '${ISO_DATE(after)}')`;
|
|
11
|
+
const rows = await getRecordByFilter(Calendar_ID, 'calendar_quimio', filter);
|
|
12
|
+
if (!rows?.length) return null;
|
|
13
|
+
return rows.sort((a, b) => new Date(a.start_time) - new Date(b.start_time))[0];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getAppointmentsBetween = async (start, end) => {
|
|
17
|
+
const filter = `AND(start_time >= '${ISO_DATE(start)}', start_time < '${ISO_DATE(end)}')`;
|
|
18
|
+
return getRecordByFilter(Calendar_ID, 'calendar_quimio', filter);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const updateAppointmentById = async (recordId, data) => {
|
|
22
|
+
return updateRecordByFilter(
|
|
23
|
+
Calendar_ID,
|
|
24
|
+
'calendar_quimio',
|
|
25
|
+
`REGEX_MATCH({record_id}, '${recordId}')`,
|
|
26
|
+
data
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const buildAvailabilityWindow = async originalDate => {
|
|
31
|
+
const start = addDays(originalDate, 1);
|
|
32
|
+
const end = addDays(originalDate, 7);
|
|
33
|
+
|
|
34
|
+
const allSlots = await getAppointmentsBetween(start, addDays(end, 1));
|
|
35
|
+
const availableSlots = allSlots.filter(row => !row.patient);
|
|
36
|
+
|
|
37
|
+
const result = {};
|
|
38
|
+
availableSlots.forEach(row => {
|
|
39
|
+
const { date, time } = dateAndTimeFromStart(row.start_time);
|
|
40
|
+
if (!result[date]) result[date] = [];
|
|
41
|
+
result[date].push({ time, availableSpots: 1 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const parseStart = parseStartTime;
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
getNextAppointmentForPatient,
|
|
51
|
+
getAppointmentsBetween,
|
|
52
|
+
updateAppointmentById,
|
|
53
|
+
parseStart,
|
|
54
|
+
buildAvailabilityWindow
|
|
55
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const { Historial_Clinico_ID } = require('../config/airtableConfig.js');
|
|
2
|
+
const { SALES_ASST, QUMIO_REMINDERS_ASST, DOCTOR_SCHEDULE_ASST, PATIENT_SCHEDULE_ASST } = require('../config/assistantConfig.js');
|
|
3
|
+
const { openaiClient } = require('../config/llmConfig.js');
|
|
4
|
+
|
|
5
|
+
const { Message, formatTimestamp } = require('../models/messageModel.js');
|
|
6
|
+
const { Thread } = require('../models/threadModel.js');
|
|
7
|
+
|
|
8
|
+
const { checkRunStatus, getCurRow } = require('../helpers/assistantHelper.js');
|
|
9
|
+
const { processMessage, getLastMessages } = require('../helpers/assistantHelper.js');
|
|
10
|
+
const { delay } = require('../helpers/whatsappHelper.js');
|
|
11
|
+
|
|
12
|
+
const { GeneralAssistant } = require('../assistants/generalAssistant.js');
|
|
13
|
+
const { SalesAssistant } = require('../assistants/salesAssistant.js');
|
|
14
|
+
const { QumioRemindersAssistant } = require('../assistants/qumioRemindersAssistant.js');
|
|
15
|
+
const { DoctorScheduleAssistant } = require('../assistants/doctorScheduleAssistant.js');
|
|
16
|
+
const { PatientScheduleAssistant } = require('../assistants/patientScheduleAssistant.js');
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const getAssistantById = (assistant_id, thread) => {
|
|
20
|
+
switch (assistant_id) {
|
|
21
|
+
case SALES_ASST:
|
|
22
|
+
return new SalesAssistant(thread);
|
|
23
|
+
case QUMIO_REMINDERS_ASST:
|
|
24
|
+
return new QumioRemindersAssistant(thread);
|
|
25
|
+
case DOCTOR_SCHEDULE_ASST:
|
|
26
|
+
return new DoctorScheduleAssistant(thread);
|
|
27
|
+
case PATIENT_SCHEDULE_ASST:
|
|
28
|
+
return new PatientScheduleAssistant(thread);
|
|
29
|
+
default:
|
|
30
|
+
return new GeneralAssistant(thread);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const createAssistant = async (code, assistant_id, messages=[], prevThread=null) => {
|
|
36
|
+
// If thread already exists, update it
|
|
37
|
+
const findThread = await Thread.findOne({ code: code });
|
|
38
|
+
if (findThread) {
|
|
39
|
+
await Thread.updateOne({ code: code }, { $set: { active: true, stopped: false, assistant_id: assistant_id } });
|
|
40
|
+
return findThread;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const curRow = await getCurRow(Historial_Clinico_ID, code);
|
|
44
|
+
console.log('curRow', curRow[0]);
|
|
45
|
+
const nombre = curRow?.[0]?.['name'] || null;
|
|
46
|
+
const patientId = curRow?.[0]?.['record_id'] || null;
|
|
47
|
+
|
|
48
|
+
const assistant = getAssistantById(assistant_id, null);
|
|
49
|
+
const initialThread = await assistant.create(code, curRow[0]);
|
|
50
|
+
|
|
51
|
+
// Add new messages to memory
|
|
52
|
+
for (const message of messages) {
|
|
53
|
+
await openaiClient.beta.threads.messages.create(
|
|
54
|
+
initialThread.id, { role: 'assistant', content: message }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Define new thread data
|
|
59
|
+
const thread = {
|
|
60
|
+
code: code,
|
|
61
|
+
assistant_id: assistant_id,
|
|
62
|
+
thread_id: initialThread.id,
|
|
63
|
+
patient_id: patientId,
|
|
64
|
+
run_id: null,
|
|
65
|
+
nombre: nombre,
|
|
66
|
+
active: true
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const condition = { thread_id: prevThread?.thread_id };
|
|
70
|
+
const options = { new: true, upsert: true };
|
|
71
|
+
const updatedThread = await Thread.findOneAndUpdate(condition, thread, options);
|
|
72
|
+
console.log('Updated thread:', updatedThread);
|
|
73
|
+
|
|
74
|
+
// Delete previous thread
|
|
75
|
+
if (prevThread) {
|
|
76
|
+
await openaiClient.beta.threads.del(prevThread.thread_id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return thread;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const addMsgAssistant = async (code, inMessages, reply = false) => {
|
|
83
|
+
try {
|
|
84
|
+
const thread = await Thread.findOne({ code: code });
|
|
85
|
+
console.log(thread);
|
|
86
|
+
if (thread === null) return null;
|
|
87
|
+
|
|
88
|
+
for (const message of inMessages) {
|
|
89
|
+
console.log(message);
|
|
90
|
+
await openaiClient.beta.threads.messages.create(
|
|
91
|
+
thread.thread_id, { role: 'assistant', content: message }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!reply) return null;
|
|
96
|
+
|
|
97
|
+
const assistant = getAssistantById(thread.assistant_id, thread);
|
|
98
|
+
const run = await openaiClient.beta.threads.runs.create(
|
|
99
|
+
thread.thread_id,
|
|
100
|
+
{
|
|
101
|
+
assistant_id: thread.assistant_id
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: run.id } });
|
|
106
|
+
await checkRunStatus(assistant, run.thread_id, run.id);
|
|
107
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: null } });
|
|
108
|
+
|
|
109
|
+
const messages = await openaiClient.beta.threads.messages.list(run.thread_id, { run_id: run.id });
|
|
110
|
+
const ans = messages.data[0].content[0].text.value;
|
|
111
|
+
console.log('THE ANS IS', ans);
|
|
112
|
+
|
|
113
|
+
return ans;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.log(error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const addInsAssistant = async (code, instruction) => {
|
|
121
|
+
try {
|
|
122
|
+
const thread = await Thread.findOne({ code: code });
|
|
123
|
+
console.log(thread);
|
|
124
|
+
if (thread === null) return null;
|
|
125
|
+
|
|
126
|
+
const assistant = getAssistantById(thread.assistant_id, thread);
|
|
127
|
+
const run = await openaiClient.beta.threads.runs.create(
|
|
128
|
+
thread.thread_id, {
|
|
129
|
+
assistant_id: thread.assistant_id,
|
|
130
|
+
additional_instructions: instruction,
|
|
131
|
+
additional_messages: [
|
|
132
|
+
{ role: 'user', content: instruction }
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: run.id } });
|
|
138
|
+
await checkRunStatus(assistant, run.thread_id, run.id);
|
|
139
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: null } });
|
|
140
|
+
|
|
141
|
+
const messages = await openaiClient.beta.threads.messages.list(run.thread_id, { run_id: run.id });
|
|
142
|
+
console.log(messages.data[0].content);
|
|
143
|
+
const ans = messages.data[0].content[0].text.value;
|
|
144
|
+
|
|
145
|
+
return ans;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.log(error);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const getThread = async (code, message = null) => {
|
|
153
|
+
try {
|
|
154
|
+
let thread = await Thread.findOne({ code: code });
|
|
155
|
+
console.log('GET THREAD');
|
|
156
|
+
console.log(thread);
|
|
157
|
+
|
|
158
|
+
if (thread === null) {
|
|
159
|
+
if (message != null) {
|
|
160
|
+
const timestamp = formatTimestamp(message.messageTimestamp);
|
|
161
|
+
await Message.updateOne({ message_id: message.key.id, timestamp: timestamp }, { $set: { processed: true } });
|
|
162
|
+
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
while (thread && thread.run_id) {
|
|
168
|
+
console.log(`Wait for ${thread.run_id} to be executed`);
|
|
169
|
+
const run = await openaiClient.beta.threads.runs.retrieve(thread.thread_id, thread.run_id);
|
|
170
|
+
if (run.status === 'cancelled' || run.status === 'expired' || run.status === 'completed') {
|
|
171
|
+
await Thread.updateOne({ code: code }, { $set: { run_id: null } });
|
|
172
|
+
}
|
|
173
|
+
thread = await Thread.findOne({ code: code, active: true });
|
|
174
|
+
await delay(5000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return thread;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Error in getThread:', error.message || error);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const getThreadInfo = async (code) => {
|
|
185
|
+
try {
|
|
186
|
+
let thread = await Thread.findOne({ code: code, active: true });
|
|
187
|
+
return thread;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.log(error);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const replyAssistant = async function (code, message_ = null, thread_ = null, runOptions = {}) {
|
|
195
|
+
try {
|
|
196
|
+
let thread = thread_ || await getThread(code);
|
|
197
|
+
console.log('THREAD STOPPED', code, thread?.active);
|
|
198
|
+
if (!thread || !thread.active) return null;
|
|
199
|
+
|
|
200
|
+
const patientReply = message_ ? [message_] : await getLastMessages(code);
|
|
201
|
+
console.log('UNREAD DATA', patientReply);
|
|
202
|
+
if (!patientReply) {
|
|
203
|
+
console.log('No relevant data found for this assistant.');
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let activeRuns = await openaiClient.beta.threads.runs.list(thread.thread_id);
|
|
208
|
+
console.log('ACTIVE RUNS:', activeRuns.length);
|
|
209
|
+
while (activeRuns.length > 0) {
|
|
210
|
+
console.log(`ACTIVE RUNS ${thread.thread_id}`);
|
|
211
|
+
activeRuns = await openaiClient.beta.threads.runs.list(thread.thread_id);
|
|
212
|
+
await delay(5000);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let patientMsg = false;
|
|
216
|
+
let urls = [];
|
|
217
|
+
for (const reply of patientReply) {
|
|
218
|
+
const { isNotAssistant, url } = await processMessage(code, reply, thread);
|
|
219
|
+
console.log(`isNotAssistant ${isNotAssistant} ${url}`);
|
|
220
|
+
patientMsg = patientMsg || isNotAssistant;
|
|
221
|
+
if (url) urls.push({ 'url': url });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (urls.length > 0) {
|
|
225
|
+
console.log('urls', urls);
|
|
226
|
+
/*for (const url of urls) {
|
|
227
|
+
console.log("url", url);
|
|
228
|
+
await addRecord(Monitoreo_ID, 'estudios', [{"fields": {"estudios": urls,
|
|
229
|
+
"combined_estudios": [ { "url": url} ], "patient_id": [thread.patient_id]}}]);
|
|
230
|
+
}
|
|
231
|
+
const { pdfBuffer, processedFiles } = await combineImagesToPDF(code);
|
|
232
|
+
console.log("AFTER COMBINED IN BUFFER", processedFiles);
|
|
233
|
+
const key = `${code}-${Date.now()}-combined.pdf`;
|
|
234
|
+
await AWS.uploadBufferToS3(pdfBuffer, bucketName, key, "application/pdf");
|
|
235
|
+
const url = await AWS.generatePresignedUrl(bucketName, key);
|
|
236
|
+
console.log("New record", {"estudios": urls, "combined_estudios":
|
|
237
|
+
[ { "url": url} ], "patient_id": [thread.patient_id]});
|
|
238
|
+
await addRecord(Monitoreo_ID, 'estudios', [{"fields": {"estudios": urls,
|
|
239
|
+
"combined_estudios": [ { "url": url} ], "patient_id": [thread.patient_id]}}]);
|
|
240
|
+
await cleanupFiles(processedFiles);*/
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
thread = await getThread(code);
|
|
244
|
+
console.log('THREAD STOPPED', code, thread?.stopped);
|
|
245
|
+
if (!patientMsg || !thread || thread?.stopped) return null;
|
|
246
|
+
|
|
247
|
+
const assistant = getAssistantById(thread.assistant_id, thread);
|
|
248
|
+
const run = await openaiClient.beta.threads.runs.create(
|
|
249
|
+
thread.thread_id,
|
|
250
|
+
{
|
|
251
|
+
assistant_id: thread.assistant_id,
|
|
252
|
+
...runOptions
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
console.log('RUN LAST ERROR:', run.last_error);
|
|
256
|
+
|
|
257
|
+
assistant.set_replies(patientReply);
|
|
258
|
+
|
|
259
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: run.id } });
|
|
260
|
+
const runStatus = await checkRunStatus(assistant, run.thread_id, run.id);
|
|
261
|
+
console.log('RUN STATUS', runStatus);
|
|
262
|
+
await Thread.updateOne({ code: thread.code, active: true }, { $set: { run_id: null } });
|
|
263
|
+
|
|
264
|
+
const messages = await openaiClient.beta.threads.messages.list(run.thread_id, { run_id: run.id });
|
|
265
|
+
const reply = messages.data?.[0]?.content?.[0]?.text?.value || '';
|
|
266
|
+
console.log(reply);
|
|
267
|
+
|
|
268
|
+
return reply;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.log(`Error inside reply assistant ${err} ${code}`);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const switchAssistant = async (code, assistant_id) => {
|
|
275
|
+
try {
|
|
276
|
+
const thread = await Thread.findOne({ code: code });
|
|
277
|
+
console.log('Inside thread', thread);
|
|
278
|
+
if (thread === null) return;
|
|
279
|
+
|
|
280
|
+
await Thread.updateOne({ code }, { $set: { assistant_id: assistant_id, active: true, stopped: false } });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.log(error);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
getThread,
|
|
289
|
+
getThreadInfo,
|
|
290
|
+
getAssistantById,
|
|
291
|
+
createAssistant,
|
|
292
|
+
replyAssistant,
|
|
293
|
+
addMsgAssistant,
|
|
294
|
+
addInsAssistant,
|
|
295
|
+
switchAssistant
|
|
296
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
const { Message } = require('../models/messageModel');
|
|
2
|
+
const { Historial_Clinico_ID } = require('../config/airtableConfig');
|
|
3
|
+
const { getRecordByFilter } = require('./airtableService');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const fetchConversationData = async (filter, skip, limit) => {
|
|
7
|
+
let filterConditions = { is_group: false };
|
|
8
|
+
|
|
9
|
+
switch (filter) {
|
|
10
|
+
case 'unread':
|
|
11
|
+
filterConditions = {
|
|
12
|
+
is_group: false,
|
|
13
|
+
from_me: false,
|
|
14
|
+
$or: [
|
|
15
|
+
{ read: false },
|
|
16
|
+
{ read: { $exists: false } }
|
|
17
|
+
]
|
|
18
|
+
};
|
|
19
|
+
console.log('Applying unread filter');
|
|
20
|
+
break;
|
|
21
|
+
|
|
22
|
+
case 'no-response':
|
|
23
|
+
console.log('Applying no-response filter');
|
|
24
|
+
break;
|
|
25
|
+
|
|
26
|
+
case 'recent': {
|
|
27
|
+
const yesterday = new Date();
|
|
28
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
29
|
+
filterConditions = {
|
|
30
|
+
is_group: false,
|
|
31
|
+
createdAt: { $gt: yesterday }
|
|
32
|
+
};
|
|
33
|
+
console.log('Applying recent filter (last 24 hours)');
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case 'all':
|
|
38
|
+
default:
|
|
39
|
+
filterConditions = { is_group: false };
|
|
40
|
+
console.log('Applying all conversations filter');
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('Executing aggregation pipeline...');
|
|
45
|
+
const aggregationStartTime = Date.now();
|
|
46
|
+
|
|
47
|
+
let aggregationPipeline = [
|
|
48
|
+
{ $match: filterConditions },
|
|
49
|
+
{ $project: {
|
|
50
|
+
numero: 1,
|
|
51
|
+
body: 1,
|
|
52
|
+
createdAt: 1,
|
|
53
|
+
timestamp: 1,
|
|
54
|
+
is_media: 1,
|
|
55
|
+
media: 1,
|
|
56
|
+
nombre_whatsapp: 1,
|
|
57
|
+
from_me: 1
|
|
58
|
+
}},
|
|
59
|
+
{ $group: {
|
|
60
|
+
_id: '$numero',
|
|
61
|
+
latestMessage: { $first: '$$ROOT' },
|
|
62
|
+
messageCount: { $sum: 1 }
|
|
63
|
+
}},
|
|
64
|
+
{ $sort: { 'latestMessage.createdAt': -1 } }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
if (filter === 'no-response') {
|
|
68
|
+
aggregationPipeline.splice(-1, 0, { $match: { 'latestMessage.from_me': false } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
aggregationPipeline.push(
|
|
72
|
+
{ $skip: skip },
|
|
73
|
+
{ $limit: limit }
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const conversations = await Message.aggregate(aggregationPipeline);
|
|
77
|
+
|
|
78
|
+
const aggregationTime = Date.now() - aggregationStartTime;
|
|
79
|
+
console.log(`Aggregation completed in ${aggregationTime}ms, found ${conversations.length} conversations`);
|
|
80
|
+
|
|
81
|
+
// Fetch names from Airtable and WhatsApp
|
|
82
|
+
const phoneNumbers = conversations.map(conv => conv._id).filter(Boolean);
|
|
83
|
+
const formula = 'OR(' +
|
|
84
|
+
phoneNumbers.map(p => `{whatsapp_id} = "${p}"`).join(', ') +
|
|
85
|
+
')';
|
|
86
|
+
const patientTable = await getRecordByFilter(Historial_Clinico_ID, 'estado_general', formula);
|
|
87
|
+
const airtableNameMap = patientTable.reduce((map, patient) => {
|
|
88
|
+
map[patient.whatsapp_id] = patient.name;
|
|
89
|
+
return map;
|
|
90
|
+
}, {});
|
|
91
|
+
console.log(`Found ${Object.keys(airtableNameMap).length} names in Airtable`);
|
|
92
|
+
|
|
93
|
+
const contactNames = await Message.aggregate([
|
|
94
|
+
{ $match: { is_group: false, from_me: false } },
|
|
95
|
+
{ $sort: { createdAt: -1 } },
|
|
96
|
+
{ $group: {
|
|
97
|
+
_id: '$numero',
|
|
98
|
+
name: { $first: '$nombre_whatsapp' }
|
|
99
|
+
}}
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const nameMap = contactNames?.reduce((map, contact) => {
|
|
103
|
+
if (contact && contact._id) {
|
|
104
|
+
if (!airtableNameMap[contact._id]) {
|
|
105
|
+
map[contact._id] = contact.name || 'Unknown';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}, {...airtableNameMap}) || airtableNameMap || {};
|
|
110
|
+
|
|
111
|
+
// Fetch unread counts
|
|
112
|
+
console.log('Fetching unread counts using Message.aggregate');
|
|
113
|
+
const unreadCounts = await Message.aggregate([
|
|
114
|
+
{
|
|
115
|
+
$match: {
|
|
116
|
+
is_group: false,
|
|
117
|
+
from_me: false,
|
|
118
|
+
$or: [
|
|
119
|
+
{ read: false },
|
|
120
|
+
{ read: { $exists: false } }
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{ $group: {
|
|
125
|
+
_id: '$numero',
|
|
126
|
+
unreadCount: { $sum: 1 }
|
|
127
|
+
}}
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const unreadMap = unreadCounts?.reduce((map, item) => {
|
|
131
|
+
if (item && item._id) {
|
|
132
|
+
map[item._id] = item.unreadCount || 0;
|
|
133
|
+
}
|
|
134
|
+
return map;
|
|
135
|
+
}, {}) || {};
|
|
136
|
+
console.log('unreadMap', JSON.stringify(unreadMap));
|
|
137
|
+
console.log('Number of conversations found:', conversations?.length || 0);
|
|
138
|
+
|
|
139
|
+
// Calculate total count for pagination
|
|
140
|
+
let totalFilterConditions = { is_group: false };
|
|
141
|
+
|
|
142
|
+
if (filter === 'unread') {
|
|
143
|
+
totalFilterConditions = {
|
|
144
|
+
is_group: false,
|
|
145
|
+
from_me: false,
|
|
146
|
+
$or: [
|
|
147
|
+
{ read: false },
|
|
148
|
+
{ read: { $exists: false } }
|
|
149
|
+
]
|
|
150
|
+
};
|
|
151
|
+
} else if (filter === 'recent') {
|
|
152
|
+
const yesterday = new Date();
|
|
153
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
154
|
+
totalFilterConditions = {
|
|
155
|
+
is_group: false,
|
|
156
|
+
createdAt: { $gt: yesterday }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let totalAggregationPipeline = [
|
|
161
|
+
{ $match: totalFilterConditions },
|
|
162
|
+
{ $group: { _id: '$numero' } },
|
|
163
|
+
{ $count: 'total' }
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
if (filter === 'no-response') {
|
|
167
|
+
totalAggregationPipeline = [
|
|
168
|
+
{ $match: { is_group: false } },
|
|
169
|
+
{ $project: {
|
|
170
|
+
numero: 1,
|
|
171
|
+
from_me: 1,
|
|
172
|
+
createdAt: 1
|
|
173
|
+
}},
|
|
174
|
+
{ $group: {
|
|
175
|
+
_id: '$numero',
|
|
176
|
+
latestMessage: { $first: '$$ROOT' }
|
|
177
|
+
}},
|
|
178
|
+
{ $match: { 'latestMessage.from_me': false } },
|
|
179
|
+
{ $count: 'total' }
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const totalConversations = await Message.aggregate(totalAggregationPipeline, { allowDiskUse: true });
|
|
184
|
+
const total = totalConversations[0]?.total || 0;
|
|
185
|
+
|
|
186
|
+
return { conversations, total, nameMap, unreadMap };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Processes conversations to prepare them for the response
|
|
191
|
+
*/
|
|
192
|
+
const processConversations = async (conversations, nameMap, unreadMap) => {
|
|
193
|
+
console.log('Processing conversations for response...');
|
|
194
|
+
|
|
195
|
+
let processedConversations = [];
|
|
196
|
+
try {
|
|
197
|
+
processedConversations = (conversations || []).map((conv, index) => {
|
|
198
|
+
try {
|
|
199
|
+
if (!conv || !conv.latestMessage) {
|
|
200
|
+
console.warn(`Conversation ${index} missing latestMessage:`, conv?._id || 'unknown');
|
|
201
|
+
return {
|
|
202
|
+
phoneNumber: conv?._id || 'unknown',
|
|
203
|
+
name: 'Unknown',
|
|
204
|
+
lastMessage: '',
|
|
205
|
+
lastMessageTime: new Date(),
|
|
206
|
+
messageCount: 0,
|
|
207
|
+
unreadCount: 0,
|
|
208
|
+
isLastMessageMedia: false,
|
|
209
|
+
lastMessageType: null,
|
|
210
|
+
lastMessageFromMe: false
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isMedia = conv.latestMessage.is_media === true;
|
|
215
|
+
let mediaType = null;
|
|
216
|
+
|
|
217
|
+
if (isMedia && conv?.latestMessage?.media) {
|
|
218
|
+
if (conv.latestMessage.media.mediaType) {
|
|
219
|
+
mediaType = conv.latestMessage.media.mediaType;
|
|
220
|
+
} else if (conv.latestMessage.media.contentType) {
|
|
221
|
+
const contentType = conv.latestMessage.media.contentType;
|
|
222
|
+
const contentTypeParts = contentType?.split('/') || ['unknown'];
|
|
223
|
+
mediaType = contentTypeParts[0] || 'unknown';
|
|
224
|
+
|
|
225
|
+
if (mediaType === 'application') {
|
|
226
|
+
mediaType = 'document';
|
|
227
|
+
} else if (contentTypeParts[1] === 'webp') {
|
|
228
|
+
mediaType = 'sticker';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
phoneNumber: conv._id,
|
|
235
|
+
name: nameMap[conv._id] || conv?.latestMessage?.nombre_whatsapp || 'Unknown',
|
|
236
|
+
lastMessage: conv?.latestMessage?.body || '',
|
|
237
|
+
lastMessageTime: conv?.latestMessage?.createdAt || conv?.latestMessage?.timestamp || new Date(),
|
|
238
|
+
messageCount: conv.messageCount || 0,
|
|
239
|
+
unreadCount: unreadMap[conv._id] || 0,
|
|
240
|
+
isLastMessageMedia: isMedia || false,
|
|
241
|
+
lastMessageType: mediaType || null,
|
|
242
|
+
lastMessageFromMe: conv?.latestMessage?.from_me || false
|
|
243
|
+
};
|
|
244
|
+
} catch (convError) {
|
|
245
|
+
console.error(`Error processing conversation ${index}:`, convError);
|
|
246
|
+
return {
|
|
247
|
+
phoneNumber: conv?._id || `error_${index}`,
|
|
248
|
+
name: 'Error Processing',
|
|
249
|
+
lastMessage: 'Error processing conversation',
|
|
250
|
+
lastMessageTime: new Date(),
|
|
251
|
+
messageCount: 0,
|
|
252
|
+
unreadCount: 0,
|
|
253
|
+
isLastMessageMedia: false,
|
|
254
|
+
lastMessageType: null,
|
|
255
|
+
lastMessageFromMe: false
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
console.log(`Successfully processed ${processedConversations.length} conversations`);
|
|
261
|
+
|
|
262
|
+
} catch (mappingError) {
|
|
263
|
+
console.error('Error in conversation mapping:', mappingError);
|
|
264
|
+
// Return empty conversations if mapping fails
|
|
265
|
+
processedConversations = [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return processedConversations;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
fetchConversationData,
|
|
273
|
+
processConversations
|
|
274
|
+
};
|