@peopl-health/nexus 3.16.2 → 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.
- package/lib/adapters/TwilioProvider.js +30 -6
- package/lib/helpers/deliveryAttemptHelper.js +53 -15
- package/lib/helpers/messageStatusHelper.js +6 -2
- package/lib/jobs/TemplateApprovalJob.js +10 -1
- package/lib/models/deliveryAttemptModel.js +8 -1
- package/lib/models/messageModel.js +16 -17
- package/lib/storage/MongoStorage.js +39 -23
- package/lib/storage/NoopStorage.js +2 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,44 +3,82 @@ 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({
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
25
|
+
try {
|
|
26
|
+
const status = errorSource ? 'failed' : (twilioResult?.status?.toLowerCase() || null);
|
|
20
27
|
|
|
21
28
|
const attempt = await DeliveryAttempt.create({
|
|
22
|
-
messageId:
|
|
29
|
+
messageId: targetId,
|
|
23
30
|
kind,
|
|
24
|
-
twilioSid:
|
|
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:
|
|
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:
|
|
39
|
-
|
|
48
|
+
twilioSid: sid, kind, error: error.message
|
|
49
|
+
});
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function updateDeliveryAttemptByTwilioSid(twilioSid, { status, errorCode = null, errorMessage = null } = {}) {
|
|
55
|
+
if (!twilioSid || !status) return null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const update = {
|
|
59
|
+
status,
|
|
60
|
+
...(errorCode != null && { errorCode: String(errorCode) }),
|
|
61
|
+
...(errorMessage != null && { errorMessage })
|
|
62
|
+
};
|
|
63
|
+
const attempt = await DeliveryAttempt.findOneAndUpdate(
|
|
64
|
+
{ twilioSid },
|
|
65
|
+
{ $set: update },
|
|
66
|
+
{ new: true }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!attempt) {
|
|
70
|
+
logger.warn('[deliveryAttemptHelper] No DeliveryAttempt found for SID; skipping update', { twilioSid });
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return attempt;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
logger.error('[deliveryAttemptHelper] Failed to update delivery attempt', {
|
|
77
|
+
twilioSid,
|
|
40
78
|
error: error.message
|
|
41
79
|
});
|
|
42
80
|
return null;
|
|
43
81
|
}
|
|
44
82
|
}
|
|
45
83
|
|
|
46
|
-
module.exports = { recordDeliveryAttempt };
|
|
84
|
+
module.exports = { recordDeliveryAttempt, updateDeliveryAttemptByTwilioSid };
|
|
@@ -6,6 +6,7 @@ const { createEvent, safeEmit } = require('../utils/eventUtils');
|
|
|
6
6
|
const { Message } = require('../models/messageModel');
|
|
7
7
|
|
|
8
8
|
const { handle24HourWindowError } = require('../helpers/templateRecoveryHelper');
|
|
9
|
+
const { updateDeliveryAttemptByTwilioSid } = require('../helpers/deliveryAttemptHelper');
|
|
9
10
|
|
|
10
11
|
const { addLinkedRecord, updateRecordByFilter } = require('../services/airtableService');
|
|
11
12
|
|
|
@@ -32,6 +33,8 @@ async function updateMessageStatus(messageSid, status, errorCode = null, errorMe
|
|
|
32
33
|
logger.warn('[MessageStatus] Message not found', { messageSid });
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
await updateDeliveryAttemptByTwilioSid(messageSid, { status, errorCode, errorMessage });
|
|
37
|
+
|
|
35
38
|
if (updated) {
|
|
36
39
|
if (status === 'failed' || status === 'undelivered') {
|
|
37
40
|
addLinkedRecord(
|
|
@@ -79,11 +82,12 @@ async function handleStatusCallback(twilioStatusData, { eventBus } = {}) {
|
|
|
79
82
|
|
|
80
83
|
if (updated && eventBus) {
|
|
81
84
|
safeEmit(eventBus, 'message:status', createEvent('message:status', {
|
|
82
|
-
messageId: updated.
|
|
85
|
+
messageId: String(updated._id),
|
|
83
86
|
to: updated.numero,
|
|
84
87
|
status: MessageStatus.toLowerCase(),
|
|
85
88
|
errorCode: ErrorCode || null,
|
|
86
|
-
errorMessage: ErrorMessage || null
|
|
89
|
+
errorMessage: ErrorMessage || null,
|
|
90
|
+
twilioSid: MessageSid
|
|
87
91
|
}));
|
|
88
92
|
}
|
|
89
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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; }
|