@peopl-health/nexus 3.5.8 → 3.5.10

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.
@@ -13,7 +13,7 @@ const { withThreadRecovery } = require('../helpers/threadRecoveryHelper');
13
13
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
14
14
 
15
15
  const { getRecordByFilter } = require('../services/airtableService');
16
- const { fetchConversationData, processConversations } = require('../services/conversationService');
16
+ const { fetchConversationData, processConversations, startConversation } = require('../services/conversationService');
17
17
 
18
18
  const { sendMessage } = require('../core/NexusMessaging');
19
19
 
@@ -672,6 +672,34 @@ const searchMessagesByNumberController = async (req, res) => {
672
672
  }
673
673
  };
674
674
 
675
+ const startConversationController = async (req, res) => {
676
+ try {
677
+ const { phoneNumber, name, message } = req.body;
678
+
679
+ if (!phoneNumber || !message) {
680
+ return res.status(400).json({
681
+ success: false,
682
+ error: 'Phone number and message are required'
683
+ });
684
+ }
685
+
686
+ const formattedPhoneNumber = ensureWhatsAppFormat(phoneNumber);
687
+ const result = await startConversation(formattedPhoneNumber, message, name);
688
+
689
+ res.status(201).json({
690
+ success: true,
691
+ message: 'Conversation started, template pending approval',
692
+ ...result
693
+ });
694
+ } catch (error) {
695
+ logger.error('[StartConversation] Error', { error: error.message });
696
+ res.status(500).json({
697
+ success: false,
698
+ error: error.message || 'Failed to start conversation'
699
+ });
700
+ }
701
+ };
702
+
675
703
  module.exports = {
676
704
  getConversationController,
677
705
  getConversationMessagesController,
@@ -682,5 +710,6 @@ module.exports = {
682
710
  markMessagesAsReadController,
683
711
  searchConversationsController,
684
712
  sendTemplateToNewNumberController,
713
+ startConversationController,
685
714
  getOpenAIThreadMessagesController
686
715
  };
@@ -229,6 +229,9 @@ class NexusMessaging {
229
229
  });
230
230
  }
231
231
 
232
+ const chatId = messageData.code;
233
+ if (chatId) ensureThreadExists(chatId);
234
+
232
235
  let content = messageData.body || messageData.message;
233
236
  if (!content && messageData.contentSid) {
234
237
  try {
@@ -0,0 +1,59 @@
1
+ const { logger } = require('../utils/logger');
2
+
3
+ const getMessaging = () => require('../core/NexusMessaging');
4
+
5
+ const MAX_ATTEMPTS = 40;
6
+ const POLL_INTERVAL_MS = 15 * 60 * 1000;
7
+
8
+ function pollTemplateApproval(templateSid, { label, logContext = {}, onApproved, onRejected }) {
9
+ const { requireProvider } = getMessaging();
10
+ const provider = requireProvider();
11
+
12
+ const poll = (attempt = 0) => {
13
+ if (attempt >= MAX_ATTEMPTS) {
14
+ logger.warn(`${label} Max approval poll attempts reached, giving up`, { ...logContext, templateSid, attempts: MAX_ATTEMPTS });
15
+ return;
16
+ }
17
+
18
+ logger.info(`${label} Scheduling approval check`, { ...logContext, templateSid, attempt, nextCheckInMs: POLL_INTERVAL_MS });
19
+
20
+ setTimeout(() => {
21
+ (async () => {
22
+ logger.info(`${label} Checking approval status`, { ...logContext, templateSid, attempt });
23
+
24
+ const status = await provider.checkApprovalStatus(templateSid);
25
+ const approvalStatus = status?.approvalRequest?.status?.toUpperCase();
26
+
27
+ logger.info(`${label} Approval status received`, { ...logContext, templateSid, approvalStatus, attempt });
28
+
29
+ if (approvalStatus === 'APPROVED') {
30
+ await onApproved(provider);
31
+ } else if (approvalStatus === 'REJECTED') {
32
+ if (onRejected) {
33
+ await onRejected(provider);
34
+ } else {
35
+ logger.warn(`${label} Template rejected`, { ...logContext, templateSid });
36
+ try {
37
+ await provider.deleteTemplate(templateSid);
38
+ logger.info(`${label} Rejected template deleted`, { ...logContext, templateSid });
39
+ } catch (deleteErr) {
40
+ logger.warn(`${label} Failed to delete rejected template`, { ...logContext, templateSid, error: deleteErr.message });
41
+ }
42
+ }
43
+ } else {
44
+ logger.info(`${label} Still pending, will retry`, { ...logContext, templateSid, approvalStatus, attempt });
45
+ poll(attempt + 1);
46
+ }
47
+ })().catch((err) => {
48
+ logger.error(`${label} Error during approval poll`, { ...logContext, templateSid, error: err.message, attempt });
49
+ if (attempt + 1 < MAX_ATTEMPTS) {
50
+ poll(attempt + 1);
51
+ }
52
+ });
53
+ }, POLL_INTERVAL_MS);
54
+ };
55
+
56
+ poll();
57
+ }
58
+
59
+ module.exports = { pollTemplateApproval };
@@ -1,6 +1,7 @@
1
1
  const { logger } = require('../utils/logger');
2
2
  const { Message } = require('../models/messageModel');
3
3
  const { Template } = require('../templates/templateStructure');
4
+ const { pollTemplateApproval } = require('./templateApprovalPoller');
4
5
 
5
6
  const getMessaging = () => require('../core/NexusMessaging');
6
7
 
@@ -29,7 +30,10 @@ async function handle24HourWindowError(message, messageSid) {
29
30
 
30
31
  const { requireProvider, sendMessage } = getMessaging();
31
32
  const provider = requireProvider();
32
- if (typeof provider.createTemplate !== 'function') return;
33
+ if (typeof provider.createTemplate !== 'function') {
34
+ logger.warn('[TemplateRecovery] Provider does not support createTemplate, skipping', { messageSid });
35
+ return;
36
+ }
33
37
 
34
38
  const templateName = `auto_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
35
39
  const template = new Template(templateName, 'UTILITY', 'es');
@@ -46,62 +50,46 @@ async function handle24HourWindowError(message, messageSid) {
46
50
 
47
51
  logger.info('[TemplateRecovery] Template created', { messageSid, templateSid: twilioContent.sid });
48
52
 
49
- const MAX_ATTEMPTS = 40;
50
- const checkApproval = async (attempt = 0) => {
51
- if (attempt >= MAX_ATTEMPTS) {
52
- logger.warn('[TemplateRecovery] Max attempts reached', { messageSid, templateSid: twilioContent.sid });
53
- return;
54
- }
53
+ pollTemplateApproval(twilioContent.sid, {
54
+ label: '[TemplateRecovery]',
55
+ logContext: { messageSid },
56
+ onApproved: async (prov) => {
57
+ const claimSend = await Message.updateOne(
58
+ { message_id: messageSid, 'statusInfo.recoverySentAt': { $exists: false } },
59
+ { $set: { 'statusInfo.recoverySentAt': new Date() } }
60
+ );
61
+ if (!claimSend?.modifiedCount && !claimSend?.nModified) {
62
+ logger.info('[TemplateRecovery] Send already claimed by another process', { messageSid, templateSid: twilioContent.sid });
63
+ return;
64
+ }
55
65
 
56
- setTimeout(async () => {
57
66
  try {
58
- const status = await provider.checkApprovalStatus(twilioContent.sid);
59
- const approvalStatus = status?.approvalRequest?.status?.toUpperCase();
60
-
61
- if (approvalStatus === 'APPROVED') {
62
- const claimSend = await Message.updateOne(
63
- { message_id: messageSid, 'statusInfo.recoverySentAt': { $exists: false } },
64
- { $set: { 'statusInfo.recoverySentAt': new Date() } }
65
- );
66
- if (!claimSend?.modifiedCount && !claimSend?.nModified) return;
67
-
68
- try {
69
- await sendMessage({ code: message.numero, contentSid: twilioContent.sid, variables: {} });
70
- logger.info('[TemplateRecovery] Template sent', { messageSid, templateSid: twilioContent.sid });
71
- try {
72
- await provider.deleteTemplate(twilioContent.sid);
73
- logger.info('[TemplateRecovery] Template deleted', { messageSid, templateSid: twilioContent.sid });
74
- } catch (deleteErr) {
75
- logger.warn('[TemplateRecovery] Failed to delete template after send', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
76
- }
77
- } catch (sendErr) {
78
- await Message.updateOne(
79
- { message_id: messageSid },
80
- { $unset: { 'statusInfo.recoverySentAt': '' } }
81
- );
82
- logger.error('[TemplateRecovery] Error sending approved template', { messageSid, templateSid: twilioContent.sid, error: sendErr.message });
83
- }
84
- } else if (approvalStatus === 'REJECTED') {
85
- logger.warn('[TemplateRecovery] Template rejected', { messageSid, templateSid: twilioContent.sid });
86
- try {
87
- await provider.deleteTemplate(twilioContent.sid);
88
- logger.info('[TemplateRecovery] Rejected template deleted', { messageSid, templateSid: twilioContent.sid });
89
- } catch (deleteErr) {
90
- logger.warn('[TemplateRecovery] Failed to delete rejected template', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
91
- }
92
- } else {
93
- checkApproval(attempt + 1);
94
- }
95
- } catch (err) {
96
- logger.error('[TemplateRecovery] Error checking approval', { error: err.message, attempt });
97
- if (attempt + 1 < MAX_ATTEMPTS) {
98
- checkApproval(attempt + 1);
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 });
99
74
  }
75
+ } catch (sendErr) {
76
+ await Message.updateOne(
77
+ { message_id: messageSid },
78
+ { $unset: { 'statusInfo.recoverySentAt': '' } }
79
+ );
80
+ logger.error('[TemplateRecovery] Error sending approved template', { messageSid, templateSid: twilioContent.sid, error: sendErr.message });
100
81
  }
101
- }, 15 * 60 * 1000);
102
- };
103
-
104
- checkApproval();
82
+ },
83
+ onRejected: async (prov) => {
84
+ logger.warn('[TemplateRecovery] Template rejected', { messageSid, templateSid: twilioContent.sid });
85
+ try {
86
+ await prov.deleteTemplate(twilioContent.sid);
87
+ logger.info('[TemplateRecovery] Rejected template deleted', { messageSid, templateSid: twilioContent.sid });
88
+ } catch (deleteErr) {
89
+ logger.warn('[TemplateRecovery] Failed to delete rejected template', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
90
+ }
91
+ }
92
+ });
105
93
 
106
94
  } catch (error) {
107
95
  logger.error('[TemplateRecovery] Error', { messageSid, error: error.message });
@@ -1,7 +1,12 @@
1
+ const { Config_ID } = require('../config/airtableConfig.js');
2
+ const runtimeConfig = require('../config/runtimeConfig.js');
3
+
1
4
  const { logger } = require('../utils/logger');
2
5
 
3
6
  const { Thread } = require('../models/threadModel.js');
4
7
 
8
+ const { getRecordByFilter } = require('../services/airtableService.js');
9
+
5
10
  const getThread = async (code, message = null) => {
6
11
  try {
7
12
  let thread = await Thread.findOne({ code });
@@ -33,9 +38,22 @@ const createPlaceholderThread = async (code) => {
33
38
  const existing = await Thread.findOne({ code });
34
39
  if (existing) return existing;
35
40
 
36
- const thread = new Thread({ code, assistant_id: null, conversation_id: null, stopped: true });
41
+ const nodeEnv = runtimeConfig.get('NODE_ENV');
42
+ const assistantStatus = nodeEnv === 'production' ? 'prod' : nodeEnv === 'development' ? 'dev' : nodeEnv;
43
+ const record = await getRecordByFilter(Config_ID, 'responses', `AND({code}="PIPO_ASST", {status}="${assistantStatus}")`, undefined, ['prompt_id']);
44
+ const prompt_id = record?.[0]?.['prompt_id'] || null;
45
+
46
+ const thread = new Thread({ code, assistant_id: null, prompt_id, conversation_id: null, stopped: true });
37
47
  await thread.save();
38
- logger.info('[createPlaceholderThread] Created', { code });
48
+ logger.info('[createPlaceholderThread] Created', { code, prompt_id });
49
+
50
+ if (prompt_id) {
51
+ // Imported here to avoid circular dependency
52
+ const { createAssistant } = require('../services/assistantService.js');
53
+ await createAssistant(code, prompt_id, [], true);
54
+ logger.info('[createPlaceholderThread] Assistant created', { code, prompt_id });
55
+ }
56
+
39
57
  return thread;
40
58
  } catch (error) {
41
59
  logger.error('[createPlaceholderThread] Error', { code, error: error.message });
@@ -21,6 +21,7 @@ const conversationRouteDefinitions = {
21
21
  'GET /:phoneNumber/search': 'searchMessagesByNumberController',
22
22
  'POST /reply': 'getConversationReplyController',
23
23
  'POST /send-template': 'sendTemplateToNewNumberController',
24
+ 'POST /start': 'startConversationController',
24
25
  'POST /:phoneNumber/read': 'markMessagesAsReadController',
25
26
  'POST /case-documentation': 'caseDocumentationController',
26
27
  'POST /report-bug': 'reportBugController',
@@ -133,6 +134,7 @@ const builtInControllers = {
133
134
  getOpenAIThreadMessagesController: conversationController.getOpenAIThreadMessagesController,
134
135
  getConversationReplyController: conversationController.getConversationReplyController,
135
136
  sendTemplateToNewNumberController: conversationController.sendTemplateToNewNumberController,
137
+ startConversationController: conversationController.startConversationController,
136
138
  markMessagesAsReadController: conversationController.markMessagesAsReadController,
137
139
  searchMessagesByNumberController: conversationController.searchMessagesByNumberController,
138
140
  reportBugController: bugReportController.reportBugController,
@@ -1,11 +1,20 @@
1
1
  const { Historial_Clinico_ID } = require('../config/airtableConfig');
2
2
 
3
3
  const { logger } = require('../utils/logger');
4
+ const { parseDate } = require('../utils/dateUtils');
4
5
 
5
6
  const { Message } = require('../models/messageModel');
7
+ const { Thread } = require('../models/threadModel');
8
+ const TemplateModel = require('../models/templateModel');
9
+
10
+ const { pollTemplateApproval } = require('../helpers/templateApprovalPoller');
6
11
 
7
12
  const { getRecordByFilter } = require('../services/airtableService');
8
13
 
14
+ const { sendMessage, requireProvider } = require('../core/NexusMessaging');
15
+
16
+ const { Template } = require('../templates/templateStructure');
17
+
9
18
  const BASE_MATCH = { group_id: null };
10
19
  const UNREAD_MATCH = { from_me: false, $or: [{ read: false }, { read: { $exists: false } }] };
11
20
  const PENDING_REVIEW_MATCH = { $or: [{ review: false }, { review: null }] };
@@ -151,7 +160,105 @@ const processConversations = async (conversations, nameMap, unreadMap) => {
151
160
  }
152
161
  };
153
162
 
163
+ const startConversation = async (phoneNumber, message, name) => {
164
+ const thread = await Thread.findOneAndUpdate(
165
+ { code: phoneNumber },
166
+ {
167
+ code: phoneNumber,
168
+ ...(name && { nombre: name }),
169
+ active: true
170
+ },
171
+ { upsert: true, new: true }
172
+ );
173
+
174
+ logger.info('[StartConversation] Thread ready', { phoneNumber, threadId: thread._id, name });
175
+
176
+ const provider = requireProvider();
177
+ const templateName = `start_${Date.now()}`;
178
+ const template = new Template(templateName, 'UTILITY', 'es');
179
+ template.setBody(message, []);
180
+ const twilioContent = await template.save();
181
+
182
+ const currentDate = new Date();
183
+ const dateCreated = parseDate(twilioContent.dateCreated, currentDate);
184
+
185
+ await TemplateModel.create({
186
+ sid: twilioContent.sid,
187
+ name: (twilioContent.friendlyName || `template_${twilioContent.sid}`).replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(),
188
+ friendlyName: twilioContent.friendlyName,
189
+ category: 'UTILITY',
190
+ language: 'es',
191
+ status: twilioContent.status,
192
+ body: message,
193
+ variables: [],
194
+ components: twilioContent.components || [],
195
+ dateCreated,
196
+ lastUpdated: currentDate
197
+ });
198
+
199
+ const timestamp = Date.now().toString();
200
+ const randomSuffix = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
201
+ const approvalName = `${templateName}_${timestamp}_${randomSuffix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
202
+ const approvalResponse = await provider.submitForApproval(twilioContent.sid, approvalName, 'UTILITY');
203
+
204
+ const validSubmittedDate = parseDate(approvalResponse.date_created || approvalResponse.dateCreated);
205
+ const validUpdatedDate = parseDate(approvalResponse.date_updated || approvalResponse.dateUpdated, validSubmittedDate);
206
+
207
+ await TemplateModel.updateOne(
208
+ { sid: twilioContent.sid },
209
+ {
210
+ status: 'PENDING',
211
+ approvalRequest: {
212
+ sid: approvalResponse.sid,
213
+ status: approvalResponse.status || 'PENDING',
214
+ dateSubmitted: validSubmittedDate,
215
+ dateUpdated: validUpdatedDate,
216
+ rejectionReason: approvalResponse.rejection_reason || ''
217
+ }
218
+ }
219
+ );
220
+
221
+ logger.info('[StartConversation] Template created and submitted for approval', {
222
+ phoneNumber,
223
+ templateSid: twilioContent.sid,
224
+ approvalStatus: approvalResponse.status
225
+ });
226
+
227
+ pollTemplateApproval(twilioContent.sid, {
228
+ label: '[StartConversation]',
229
+ logContext: { phoneNumber },
230
+ onApproved: async (prov) => {
231
+ await sendMessage({ code: phoneNumber, contentSid: twilioContent.sid, variables: {} });
232
+ logger.info('[StartConversation] Template sent successfully', { phoneNumber, templateSid: twilioContent.sid });
233
+ await TemplateModel.updateOne({ sid: twilioContent.sid }, { status: 'APPROVED' });
234
+ try {
235
+ await prov.deleteTemplate(twilioContent.sid);
236
+ logger.info('[StartConversation] Template cleaned up', { templateSid: twilioContent.sid });
237
+ } catch (deleteErr) {
238
+ logger.warn('[StartConversation] Failed to delete template after send', { templateSid: twilioContent.sid, error: deleteErr.message });
239
+ }
240
+ },
241
+ onRejected: async (prov) => {
242
+ logger.warn('[StartConversation] Template rejected', { phoneNumber, templateSid: twilioContent.sid });
243
+ await TemplateModel.updateOne({ sid: twilioContent.sid }, { status: 'REJECTED' });
244
+ try {
245
+ await prov.deleteTemplate(twilioContent.sid);
246
+ } catch (deleteErr) {
247
+ logger.warn('[StartConversation] Failed to delete rejected template', { templateSid: twilioContent.sid, error: deleteErr.message });
248
+ }
249
+ }
250
+ });
251
+
252
+ return {
253
+ phoneNumber,
254
+ threadId: thread._id,
255
+ contentSid: twilioContent.sid,
256
+ approvalStatus: approvalResponse.status || 'PENDING'
257
+ };
258
+ };
259
+
154
260
  module.exports = {
155
261
  fetchConversationData,
156
- processConversations
262
+ processConversations,
263
+ startConversation
157
264
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.5.8",
3
+ "version": "3.5.10",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",