@peopl-health/nexus 3.16.3 → 4.0.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.
@@ -130,9 +130,9 @@ class TwilioProvider extends MessageProvider {
130
130
  timestamp: new Date(),
131
131
  fromMe: true,
132
132
  processed: messageData.processed ?? false,
133
- statusInfo: {
134
- status: result.status?.toLowerCase() || null,
135
- updatedAt: result.dateCreated || new Date()
133
+ statusInfo: {
134
+ status: result.status?.toLowerCase() || null,
135
+ updatedAt: new Date()
136
136
  }
137
137
  });
138
138
  } catch (err) {
@@ -153,9 +153,33 @@ class TwilioProvider extends MessageProvider {
153
153
  if (i < chunks.length - 1) await new Promise(r => setTimeout(r, 100));
154
154
  }
155
155
  } else {
156
- result = await this.twilioClient.messages.create(messageParams);
157
- await saveMessage(messageParams.body || messageData.body, result);
158
- await recordDeliveryAttempt({ messageData, twilioResult: result, kind });
156
+ let pending = null;
157
+ if (!messageData._skipStorage && this.messageStorage?.savePendingMessage) {
158
+ pending = await this.messageStorage.savePendingMessage({
159
+ ...messageData, code: formattedCode, from: formattedFrom,
160
+ provider: 'twilio', timestamp: new Date(), fromMe: true,
161
+ processed: messageData.processed ?? false
162
+ });
163
+ }
164
+
165
+ try {
166
+ result = await this.twilioClient.messages.create(messageParams);
167
+ } catch (twilioErr) {
168
+ await recordDeliveryAttempt({
169
+ messageData, messageId: pending?._id, kind,
170
+ errorSource: 'twilio_sync',
171
+ errorCode: twilioErr.code, errorMessage: twilioErr.message
172
+ });
173
+ throw twilioErr;
174
+ }
175
+
176
+ if (pending) {
177
+ await this.messageStorage.finalizePendingMessage(pending._id, result.sid, {
178
+ status: result.status?.toLowerCase() || null,
179
+ updatedAt: new Date()
180
+ });
181
+ }
182
+ await recordDeliveryAttempt({ messageData, messageId: pending?._id, twilioResult: result, kind });
159
183
  }
160
184
 
161
185
  return {
@@ -3,41 +3,49 @@ const { logger } = require('../utils/logger');
3
3
  const { Message } = require('../models/messageModel');
4
4
  const { DeliveryAttempt } = require('../models/deliveryAttemptModel');
5
5
 
6
- async function recordDeliveryAttempt({ messageData, twilioResult, kind }) {
7
- if (messageData?._skipStorage) return null;
8
- if (!twilioResult?.sid) return null;
6
+ async function recordDeliveryAttempt({
7
+ messageData = null, messageId = null, twilioResult = null, kind,
8
+ errorSource = null, errorCode = null, errorMessage = null
9
+ }) {
10
+ const sid = twilioResult?.sid || null;
11
+ if (!sid && !errorSource) return null;
9
12
 
10
- try {
11
- const msgDoc = await Message.findOne({ message_id: twilioResult.sid }, '_id').lean();
13
+ let targetId = messageId;
14
+ if (!targetId) {
15
+ if (messageData?._skipStorage) return null;
16
+ if (!sid) return null;
17
+ const msgDoc = await Message.findOne({ message_id: sid }, '_id').lean();
12
18
  if (!msgDoc) {
13
- logger.warn('[deliveryAttemptHelper] Message not found for SID; skipping attempt record', {
14
- twilioSid: twilioResult.sid
15
- });
19
+ logger.warn('[deliveryAttemptHelper] Message not found for SID; skipping attempt record', { twilioSid: sid });
16
20
  return null;
17
21
  }
22
+ targetId = msgDoc._id;
23
+ }
18
24
 
19
- const status = twilioResult.status?.toLowerCase() || 'sent';
25
+ try {
26
+ const status = errorSource ? 'failed' : (twilioResult?.status?.toLowerCase() || null);
20
27
 
21
28
  const attempt = await DeliveryAttempt.create({
22
- messageId: msgDoc._id,
29
+ messageId: targetId,
23
30
  kind,
24
- twilioSid: twilioResult.sid,
31
+ twilioSid: sid,
25
32
  contentSid: messageData?.contentSid || null,
26
33
  status,
34
+ errorSource,
35
+ errorCode,
36
+ errorMessage,
27
37
  completedAt: new Date()
28
38
  });
29
39
 
30
40
  await Message.updateOne(
31
- { _id: msgDoc._id },
41
+ { _id: targetId },
32
42
  { $set: { 'statusInfo.latestDeliveryStatus': status } }
33
43
  );
34
44
 
35
45
  return attempt;
36
46
  } catch (error) {
37
47
  logger.error('[deliveryAttemptHelper] Failed to record delivery attempt', {
38
- twilioSid: twilioResult?.sid,
39
- kind,
40
- error: error.message
48
+ twilioSid: sid, kind, error: error.message
41
49
  });
42
50
  return null;
43
51
  }
@@ -82,11 +82,12 @@ async function handleStatusCallback(twilioStatusData, { eventBus } = {}) {
82
82
 
83
83
  if (updated && eventBus) {
84
84
  safeEmit(eventBus, 'message:status', createEvent('message:status', {
85
- messageId: updated.message_id,
85
+ messageId: String(updated._id),
86
86
  to: updated.numero,
87
87
  status: MessageStatus.toLowerCase(),
88
88
  errorCode: ErrorCode || null,
89
- errorMessage: ErrorMessage || null
89
+ errorMessage: ErrorMessage || null,
90
+ twilioSid: MessageSid
90
91
  }));
91
92
  }
92
93
 
@@ -1,5 +1,6 @@
1
1
  const { logger } = require('../utils/logger');
2
2
  const { Message } = require('../models/messageModel');
3
+ const { recordDeliveryAttempt } = require('../helpers/deliveryAttemptHelper');
3
4
  const { BaseJob } = require('./BaseJob');
4
5
 
5
6
  const QUEUE_NAME = 'template-approval';
@@ -110,8 +111,9 @@ class TemplateApprovalJob extends BaseJob {
110
111
  }
111
112
 
112
113
  let recoveryMessageId;
114
+ let sendResult;
113
115
  try {
114
- const sendResult = await this.sendMessage({
116
+ sendResult = await this.sendMessage({
115
117
  code: message.numero,
116
118
  contentSid: templateSid,
117
119
  variables: {},
@@ -142,6 +144,13 @@ class TemplateApprovalJob extends BaseJob {
142
144
  });
143
145
  }
144
146
 
147
+ await recordDeliveryAttempt({
148
+ messageData: { contentSid: templateSid },
149
+ messageId: message._id,
150
+ twilioResult: { sid: recoveryMessageId, status: sendResult?.status },
151
+ kind: 'recovery_template'
152
+ });
153
+
145
154
  provider.deleteTemplate(templateSid).catch((deleteErr) =>
146
155
  logger.warn('[TemplateApprovalJob] Failed to delete template after send', { templateSid, error: deleteErr.message })
147
156
  );
@@ -2,6 +2,7 @@ const mongoose = require('mongoose');
2
2
 
3
3
  const KIND_VALUES = ['freeform', 'template', 'recovery_template'];
4
4
  const STATUS_VALUES = [null, 'queued', 'sending', 'sent', 'delivered', 'undelivered', 'failed', 'read'];
5
+ const ERROR_SOURCE_VALUES = [null, 'twilio_sync', 'twilio_async', 'server'];
5
6
 
6
7
  const deliveryAttemptSchema = new mongoose.Schema({
7
8
  messageId: {
@@ -23,6 +24,11 @@ const deliveryAttemptSchema = new mongoose.Schema({
23
24
  },
24
25
  errorCode: { type: String, default: null },
25
26
  errorMessage: { type: String, default: null },
27
+ errorSource: {
28
+ type: String,
29
+ enum: ERROR_SOURCE_VALUES,
30
+ default: null
31
+ },
26
32
  attemptedAt: { type: Date, default: Date.now, required: true },
27
33
  completedAt: { type: Date, default: null },
28
34
  raw: { type: mongoose.Schema.Types.Mixed, default: null }
@@ -37,5 +43,6 @@ const DeliveryAttempt = mongoose.model('DeliveryAttempt', deliveryAttemptSchema)
37
43
  module.exports = {
38
44
  DeliveryAttempt,
39
45
  DELIVERY_ATTEMPT_KINDS: KIND_VALUES,
40
- DELIVERY_ATTEMPT_STATUSES: STATUS_VALUES
46
+ DELIVERY_ATTEMPT_STATUSES: STATUS_VALUES,
47
+ DELIVERY_ATTEMPT_ERROR_SOURCES: ERROR_SOURCE_VALUES
41
48
  };
@@ -132,28 +132,27 @@ async function insertMessage(values) {
132
132
  values.clinical_context = clinicalContext;
133
133
  }
134
134
  const messageData = Object.fromEntries(
135
- Object.entries(values)
136
- .filter(([k, v]) => v !== undefined && !k.startsWith('delivery_'))
135
+ Object.entries(values).filter(([, v]) => v !== undefined)
137
136
  );
138
137
 
139
- if (!messageData.statusInfo && values.delivery_status) {
140
- messageData.statusInfo = {
141
- status: values.delivery_status,
142
- errorCode: values.delivery_error_code || null,
143
- errorMessage: values.delivery_error_message || null,
144
- updatedAt: values.delivery_status_updated_at || null
145
- };
146
- }
147
138
  if (!Array.isArray(messageData.tools_executed)) {
148
139
  messageData.tools_executed = [];
149
140
  }
150
141
 
151
- const result = await Message.findOneAndUpdate(
152
- { message_id: values.message_id, body: values.body },
153
- { $setOnInsert: messageData },
154
- { upsert: true, new: true, setDefaultsOnInsert: true, includeResultMetadata: true }
155
- );
156
- const isNew = !result?.lastErrorObject?.updatedExisting;
142
+ let doc;
143
+ let isNew;
144
+ if (values.message_id == null) {
145
+ doc = await Message.create(messageData);
146
+ isNew = true;
147
+ } else {
148
+ const result = await Message.findOneAndUpdate(
149
+ { message_id: values.message_id, body: values.body },
150
+ { $setOnInsert: messageData },
151
+ { upsert: true, new: true, setDefaultsOnInsert: true, includeResultMetadata: true }
152
+ );
153
+ doc = result.value;
154
+ isNew = !result.lastErrorObject?.updatedExisting;
155
+ }
157
156
 
158
157
  if (isNew && values.numero && !INTERNAL_ORIGINS.has(values.origin)) {
159
158
  Thread.findOneAndUpdate(
@@ -180,7 +179,7 @@ async function insertMessage(values) {
180
179
  }).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
181
180
 
182
181
  logger.info('[MongoStorage] Message inserted or updated successfully');
183
- return { isNew };
182
+ return { isNew, doc };
184
183
  } catch (err) {
185
184
  logger.error('[MongoStorage] Error inserting message', { error: err.message, stack: err.stack });
186
185
  throw err;
@@ -37,29 +37,9 @@ class MongoStorage {
37
37
  async saveMessage(messageData) {
38
38
  try {
39
39
  const values = this.buildMessageValues(messageData);
40
- await insertMessage(values);
40
+ const { doc } = await insertMessage(values);
41
41
  logger.info('[MongoStorage] Message stored');
42
-
43
- const chatId = messageData.from;
44
- const messageId = messageData.messageId;
45
-
46
- if (chatId && messageId) {
47
- safeEmit(getStatusEventBus(), 'message:new', createEvent('message:new', {
48
- messageId,
49
- from: chatId,
50
- code: values.numero,
51
- name: values.nombre_whatsapp,
52
- origin: values.origin,
53
- body: values.body,
54
- media: values.media,
55
- type: values.interactive_type ? 'interactive' : values.media ? 'media' : 'message',
56
- triggeredBy: values.triggeredBy,
57
- ...(messageData.frontendId && { frontendId: messageData.frontendId })
58
- }));
59
- } else {
60
- logger.warn('[processIncomingMessage] Skipping event emission: missing chatId or messageId', { chatId, messageId });
61
- }
62
-
42
+ this._emitMessageNew(doc, messageData.from, messageData.frontendId);
63
43
  return values;
64
44
  } catch (error) {
65
45
  logger.error('Error saving message', { error });
@@ -67,9 +47,45 @@ class MongoStorage {
67
47
  }
68
48
  }
69
49
 
50
+ async savePendingMessage(messageData) {
51
+ const values = this.buildMessageValues(messageData);
52
+ const { doc } = await insertMessage(values);
53
+ this._emitMessageNew(doc, messageData.from, messageData.frontendId, 'queued');
54
+ return doc;
55
+ }
56
+
57
+ async finalizePendingMessage(docId, sid, statusInfo = null) {
58
+ await Message.updateOne(
59
+ { _id: docId },
60
+ { $set: { message_id: sid, ...(statusInfo && { statusInfo }) } }
61
+ );
62
+ safeEmit(getStatusEventBus(), 'message:status', createEvent('message:status', {
63
+ messageId: String(docId),
64
+ status: statusInfo?.status || null,
65
+ twilioSid: sid
66
+ }));
67
+ }
68
+
69
+ _emitMessageNew(doc, from, frontendId, status = null) {
70
+ if (!from || !doc?._id) return;
71
+ safeEmit(getStatusEventBus(), 'message:new', createEvent('message:new', {
72
+ messageId: String(doc._id),
73
+ from,
74
+ code: doc.numero,
75
+ name: doc.nombre_whatsapp,
76
+ origin: doc.origin,
77
+ body: doc.body,
78
+ media: doc.media,
79
+ type: doc.interactive_type ? 'interactive' : doc.media ? 'media' : 'message',
80
+ triggeredBy: doc.triggeredBy,
81
+ status,
82
+ ...(frontendId && { frontendId })
83
+ }));
84
+ }
85
+
70
86
  buildMessageValues(messageData = {}) {
71
87
  const now = new Date();
72
- const messageId = messageData.messageId || `pending-${now.getTime()}-${Math.floor(Math.random()*1000)}`;
88
+ const messageId = messageData.messageId ?? null;
73
89
  const fromMe = messageData.fromMe !== undefined ? messageData.fromMe : true;
74
90
 
75
91
  const values = {
@@ -4,6 +4,8 @@ class NoopStorage {
4
4
  }
5
5
  async connect() {}
6
6
  async saveMessage() { return null; }
7
+ async savePendingMessage() { return null; }
8
+ async finalizePendingMessage() { return null; }
7
9
  async saveInteractive() { return null; }
8
10
  async getMessages() { return []; }
9
11
  async getThread() { return null; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.16.3",
3
+ "version": "4.0.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",