@peopl-health/nexus 3.15.4 → 3.15.6

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.
@@ -22,6 +22,8 @@ const { ProcessingPipeline } = require('../core/ProcessingPipeline');
22
22
  const { AssistantProcessor } = require('../core/AssistantProcessor');
23
23
 
24
24
  const { createQueueAdapter } = require('../queue');
25
+ const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
26
+ const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
25
27
 
26
28
  /**
27
29
  * Core messaging orchestrator for providers, storage, and assistant processing.
@@ -77,6 +79,21 @@ class NexusMessaging {
77
79
  sendMessage: this.sendMessage.bind(this),
78
80
  storeRunMetrics,
79
81
  });
82
+
83
+ this.scheduledMessageJob = new ScheduledMessageJob({
84
+ queueAdapter: this.queueAdapter,
85
+ sendMessage: this.sendMessage.bind(this)
86
+ });
87
+
88
+ this.templateApprovalJob = new TemplateApprovalJob({
89
+ queueAdapter: this.queueAdapter,
90
+ sendMessage: this.sendMessage.bind(this),
91
+ requireProvider: () => {
92
+ const provider = this.getProvider();
93
+ if (!provider) throw new Error('Nexus provider not configured');
94
+ return provider;
95
+ }
96
+ });
80
97
  }
81
98
 
82
99
  async initialize(options = {}) {
@@ -257,10 +274,25 @@ class NexusMessaging {
257
274
  }
258
275
 
259
276
  async sendScheduledMessage(scheduledMessage) {
260
- if (!this.provider) {
261
- throw new Error('No provider initialized');
277
+ if (!scheduledMessage?._id) {
278
+ throw new Error('sendScheduledMessage requires a persisted ScheduledMessage with _id');
262
279
  }
263
- return await this.provider.sendScheduledMessage(scheduledMessage);
280
+ if (!scheduledMessage?.sendTime) {
281
+ throw new Error('sendScheduledMessage requires sendTime');
282
+ }
283
+ return await this.scheduledMessageJob.schedule({
284
+ scheduledMessageId: scheduledMessage._id,
285
+ sendTime: scheduledMessage.sendTime
286
+ });
287
+ }
288
+
289
+ async scheduleTemplateApproval({ templateSid, messageId, originalMessageSid = null, attempt = 0 }) {
290
+ return await this.templateApprovalJob.schedule({
291
+ templateSid,
292
+ messageId,
293
+ originalMessageSid,
294
+ attempt
295
+ });
264
296
  }
265
297
 
266
298
  /*
@@ -546,6 +578,10 @@ const sendScheduledMessage = async (scheduledMessage) => {
546
578
  return await requireDefaultInstance().sendScheduledMessage(scheduledMessage);
547
579
  };
548
580
 
581
+ const scheduleTemplateApproval = async (options) => {
582
+ return await requireDefaultInstance().scheduleTemplateApproval(options);
583
+ };
584
+
549
585
  const processInstruction = async (code, instruction, role, options) => {
550
586
  return await requireDefaultInstance().processInstruction(code, instruction, role, options);
551
587
  };
@@ -562,6 +598,7 @@ module.exports = {
562
598
  NexusMessaging,
563
599
  sendMessage,
564
600
  sendScheduledMessage,
601
+ scheduleTemplateApproval,
565
602
  processInstruction,
566
603
  processSystemMessage,
567
604
  setDefaultInstance,
@@ -1,7 +1,6 @@
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');
5
4
 
6
5
  const getMessaging = () => require('../core/NexusMessaging');
7
6
 
@@ -30,7 +29,7 @@ async function handle24HourWindowError(message, messageSid) {
30
29
  return;
31
30
  }
32
31
 
33
- const { requireProvider } = getMessaging();
32
+ const { requireProvider, scheduleTemplateApproval } = getMessaging();
34
33
  const provider = requireProvider();
35
34
  if (typeof provider.createTemplate !== 'function') {
36
35
  logger.warn('[TemplateRecovery] Provider does not support createTemplate, skipping', { messageSid });
@@ -52,65 +51,11 @@ async function handle24HourWindowError(message, messageSid) {
52
51
 
53
52
  logger.info('[TemplateRecovery] Template created', { messageSid, templateSid: twilioContent.sid });
54
53
 
55
- pollTemplateApproval(twilioContent.sid, {
56
- label: '[TemplateRecovery]',
57
- logContext: { messageSid },
58
- onApproved: async (prov) => {
59
- const claimSend = await Message.updateOne(
60
- { _id: messageDocId, 'statusInfo.recoverySentAt': { $exists: false } },
61
- { $set: { 'statusInfo.recoverySentAt': new Date() } }
62
- );
63
- if (!claimSend?.modifiedCount && !claimSend?.nModified) {
64
- logger.info('[TemplateRecovery] Send already claimed by another process', { messageSid, templateSid: twilioContent.sid });
65
- return;
66
- }
67
-
68
- let newMessageId;
69
- try {
70
- const sendResult = await provider.sendMessage({
71
- code: message.numero,
72
- contentSid: twilioContent.sid,
73
- variables: {},
74
- _skipStorage: true
75
- });
76
- newMessageId = sendResult?.messageId;
77
- if (!newMessageId) throw new Error('Provider did not return a messageId for the recovery send');
78
- } catch (sendErr) {
79
- await Message.updateOne(
80
- { _id: messageDocId },
81
- { $unset: { 'statusInfo.recoverySentAt': '' } }
82
- );
83
- logger.error('[TemplateRecovery] Error sending approved template', { messageSid, templateSid: twilioContent.sid, error: sendErr.message });
84
- return;
85
- }
86
-
87
- try {
88
- await Message.updateOne(
89
- { _id: messageDocId },
90
- { $set: { 'statusInfo.recoveryMessageId': newMessageId } }
91
- );
92
- logger.info('[TemplateRecovery] Recovered', { originalMessageId: messageSid, recoveryMessageId: newMessageId, templateSid: twilioContent.sid });
93
- } catch (dbErr) {
94
- logger.error('[TemplateRecovery] CRITICAL: send succeeded but DB update failed — status tracking lost for recovery delivery', {
95
- messageSid, newMessageId, templateSid: twilioContent.sid, error: dbErr.message
96
- });
97
- }
98
-
99
- prov.deleteTemplate(twilioContent.sid).catch(deleteErr =>
100
- logger.warn('[TemplateRecovery] Failed to delete template after send', { newMessageId, templateSid: twilioContent.sid, error: deleteErr.message })
101
- );
102
- },
103
- onRejected: async (prov) => {
104
- logger.warn('[TemplateRecovery] Template rejected', { messageSid, templateSid: twilioContent.sid });
105
- try {
106
- await prov.deleteTemplate(twilioContent.sid);
107
- logger.info('[TemplateRecovery] Rejected template deleted', { messageSid, templateSid: twilioContent.sid });
108
- } catch (deleteErr) {
109
- logger.warn('[TemplateRecovery] Failed to delete rejected template', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
110
- }
111
- }
54
+ await scheduleTemplateApproval({
55
+ templateSid: twilioContent.sid,
56
+ messageId: messageDocId,
57
+ originalMessageSid: messageSid
112
58
  });
113
-
114
59
  } catch (error) {
115
60
  logger.error('[TemplateRecovery] Error', { messageSid, error: error.message });
116
61
  }
@@ -1,4 +1,5 @@
1
1
  const { logger } = require('../utils/logger');
2
+ const { ScheduledMessage } = require('../models/agendaMessageModel');
2
3
  const { BaseJob } = require('./BaseJob');
3
4
 
4
5
  const QUEUE_NAME = 'scheduled-messages';
@@ -10,9 +11,16 @@ const DEFAULT_JOB_OPTIONS = {
10
11
  removeOnFail: 50
11
12
  };
12
13
 
14
+ const TERMINAL_STATUSES = ['sent', 'cancelled'];
15
+ const STALE_SENDING_MS = 5 * 60 * 1000;
16
+
13
17
  class ScheduledMessageJob extends BaseJob {
14
- constructor({ queueAdapter } = {}) {
18
+ constructor({ queueAdapter, sendMessage } = {}) {
15
19
  super({ queueAdapter, queueName: QUEUE_NAME });
20
+ if (typeof sendMessage !== 'function') {
21
+ throw new Error('ScheduledMessageJob requires a sendMessage function');
22
+ }
23
+ this.sendMessage = sendMessage;
16
24
  }
17
25
 
18
26
  async schedule({ scheduledMessageId, sendTime }) {
@@ -47,9 +55,79 @@ class ScheduledMessageJob extends BaseJob {
47
55
  return `scheduled-msg-${scheduledMessageId}`;
48
56
  }
49
57
 
50
- async _process(data) {
51
- logger.warn('[ScheduledMessageJob] Processor stub invoked; real implementation pending', data);
52
- return { success: false, reason: 'not_implemented' };
58
+ async _process({ scheduledMessageId }) {
59
+ const msg = await ScheduledMessage.findById(scheduledMessageId);
60
+
61
+ if (!msg) {
62
+ logger.warn('[ScheduledMessageJob] Scheduled message not found', { scheduledMessageId });
63
+ return { success: false, reason: 'not_found' };
64
+ }
65
+
66
+ if (TERMINAL_STATUSES.includes(msg.status)) {
67
+ logger.info('[ScheduledMessageJob] Already processed', { scheduledMessageId, status: msg.status });
68
+ return { success: false, reason: 'already_processed', status: msg.status };
69
+ }
70
+
71
+ const staleThreshold = new Date(Date.now() - STALE_SENDING_MS);
72
+ const claim = await ScheduledMessage.updateOne(
73
+ {
74
+ _id: scheduledMessageId,
75
+ $or: [
76
+ { status: { $nin: TERMINAL_STATUSES.concat(['sending']) } },
77
+ { status: 'sending', sendingAt: { $lt: staleThreshold } }
78
+ ]
79
+ },
80
+ { $set: { status: 'sending', sendingAt: new Date() } }
81
+ );
82
+ if (!claim.modifiedCount && !claim.nModified) {
83
+ logger.info('[ScheduledMessageJob] Send already claimed by another worker', { scheduledMessageId });
84
+ return { success: false, reason: 'already_claimed' };
85
+ }
86
+ if (msg.status === 'sending') {
87
+ logger.warn('[ScheduledMessageJob] Re-claimed stale sending state, prior worker likely crashed', {
88
+ scheduledMessageId,
89
+ priorSendingAt: msg.sendingAt
90
+ });
91
+ }
92
+
93
+ try {
94
+ const result = await this.sendMessage({
95
+ code: msg.code,
96
+ body: msg.message,
97
+ fileUrl: msg.fileUrl,
98
+ fileType: msg.fileType,
99
+ contentSid: msg.contentSid,
100
+ variables: msg.variables,
101
+ hidePreview: msg.hidePreview
102
+ });
103
+
104
+ const messageId = result?.messageId || result?.sid || null;
105
+
106
+ await ScheduledMessage.updateOne(
107
+ { _id: scheduledMessageId },
108
+ { $set: { status: 'sent', sentAt: new Date(), wa_id: messageId } }
109
+ );
110
+
111
+ logger.info('[ScheduledMessageJob] Sent', { scheduledMessageId, messageId });
112
+
113
+ return { success: true, messageId };
114
+ } catch (error) {
115
+ await ScheduledMessage.updateOne(
116
+ { _id: scheduledMessageId },
117
+ {
118
+ $set: {
119
+ status: 'failed',
120
+ failedAt: new Date(),
121
+ errorCode: error?.code || null,
122
+ errorMessage: error?.message || null
123
+ }
124
+ }
125
+ );
126
+
127
+ logger.error('[ScheduledMessageJob] Send failed', { scheduledMessageId, error: error.message });
128
+
129
+ throw error;
130
+ }
53
131
  }
54
132
  }
55
133
 
@@ -1,8 +1,10 @@
1
1
  const { logger } = require('../utils/logger');
2
+ const { Message } = require('../models/messageModel');
2
3
  const { BaseJob } = require('./BaseJob');
3
4
 
4
5
  const QUEUE_NAME = 'template-approval';
5
6
  const DEFAULT_DELAY_MS = 15 * 60 * 1000;
7
+ const MAX_ATTEMPTS = 40;
6
8
 
7
9
  const DEFAULT_JOB_OPTIONS = {
8
10
  attempts: 3,
@@ -12,8 +14,16 @@ const DEFAULT_JOB_OPTIONS = {
12
14
  };
13
15
 
14
16
  class TemplateApprovalJob extends BaseJob {
15
- constructor({ queueAdapter } = {}) {
17
+ constructor({ queueAdapter, sendMessage, requireProvider } = {}) {
16
18
  super({ queueAdapter, queueName: QUEUE_NAME });
19
+ if (typeof sendMessage !== 'function') {
20
+ throw new Error('TemplateApprovalJob requires a sendMessage function');
21
+ }
22
+ if (typeof requireProvider !== 'function') {
23
+ throw new Error('TemplateApprovalJob requires a requireProvider function');
24
+ }
25
+ this.sendMessage = sendMessage;
26
+ this.requireProvider = requireProvider;
17
27
  }
18
28
 
19
29
  async schedule({ templateSid, messageId, originalMessageSid = null, attempt = 0, delayMs = DEFAULT_DELAY_MS }) {
@@ -36,9 +46,123 @@ class TemplateApprovalJob extends BaseJob {
36
46
  return `template-approval-${templateSid}-${attempt}`;
37
47
  }
38
48
 
39
- async _process(data) {
40
- logger.warn('[TemplateApprovalJob] Processor stub invoked; real implementation pending', data);
41
- return { success: false, reason: 'not_implemented' };
49
+ async _process({ templateSid, messageId, originalMessageSid, attempt = 0 }) {
50
+ const message = await Message.findById(messageId).lean();
51
+
52
+ if (!message) {
53
+ logger.warn('[TemplateApprovalJob] Message not found', { messageId, templateSid });
54
+ return { success: false, reason: 'not_found' };
55
+ }
56
+
57
+ const recovery = message.statusInfo || {};
58
+ if (recovery.recoveryMessageId || recovery.recoveryRejectedAt) {
59
+ logger.info('[TemplateApprovalJob] Recovery already completed', { messageId, templateSid });
60
+ return { success: false, reason: 'already_completed' };
61
+ }
62
+ if (recovery.recoveryTemplateSid && recovery.recoveryTemplateSid !== templateSid) {
63
+ logger.info('[TemplateApprovalJob] Template superseded', { messageId, templateSid, currentTemplateSid: recovery.recoveryTemplateSid });
64
+ return { success: false, reason: 'template_superseded' };
65
+ }
66
+
67
+ let provider;
68
+ let approvalStatus;
69
+ try {
70
+ provider = this.requireProvider();
71
+ const status = await provider.checkApprovalStatus(templateSid);
72
+ approvalStatus = status?.approvalRequest?.status?.toUpperCase();
73
+ } catch (err) {
74
+ logger.error('[TemplateApprovalJob] Provider error during approval check, treating as transient', {
75
+ templateSid, messageId, attempt, error: err.message
76
+ });
77
+ return await this._rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason: 'error' });
78
+ }
79
+
80
+ logger.info('[TemplateApprovalJob] Approval status', { templateSid, approvalStatus, attempt, messageId, originalMessageSid });
81
+
82
+ if (approvalStatus === 'APPROVED') {
83
+ return await this._handleApproved({ provider, message, templateSid, originalMessageSid });
84
+ }
85
+ if (approvalStatus === 'REJECTED') {
86
+ return await this._handleRejected({ provider, message, templateSid });
87
+ }
88
+
89
+ return await this._rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason: 'pending' });
90
+ }
91
+
92
+ async _rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason }) {
93
+ const nextAttempt = attempt + 1;
94
+ if (nextAttempt >= MAX_ATTEMPTS) {
95
+ logger.warn('[TemplateApprovalJob] Max attempts reached, giving up', { templateSid, messageId, attempt, reason });
96
+ return { success: false, reason: 'max_attempts', priorReason: reason };
97
+ }
98
+ await this.schedule({ templateSid, messageId, originalMessageSid, attempt: nextAttempt });
99
+ return { success: false, reason, nextAttempt };
100
+ }
101
+
102
+ async _handleApproved({ provider, message, templateSid, originalMessageSid }) {
103
+ const claim = await Message.updateOne(
104
+ { _id: message._id, 'statusInfo.recoverySentAt': { $exists: false } },
105
+ { $set: { 'statusInfo.recoverySentAt': new Date() } }
106
+ );
107
+ if (!claim.modifiedCount && !claim.nModified) {
108
+ logger.info('[TemplateApprovalJob] Send already claimed by another worker', { templateSid, messageId: message._id });
109
+ return { success: false, reason: 'already_claimed' };
110
+ }
111
+
112
+ let recoveryMessageId;
113
+ try {
114
+ const sendResult = await this.sendMessage({
115
+ code: message.numero,
116
+ contentSid: templateSid,
117
+ variables: {},
118
+ _skipStorage: true
119
+ });
120
+ recoveryMessageId = sendResult?.messageId;
121
+ if (!recoveryMessageId) {
122
+ throw new Error('Provider did not return a messageId for the recovery send');
123
+ }
124
+ } catch (sendErr) {
125
+ await Message.updateOne(
126
+ { _id: message._id },
127
+ { $unset: { 'statusInfo.recoverySentAt': '' } }
128
+ );
129
+ logger.error('[TemplateApprovalJob] Send failed', { templateSid, messageId: message._id, error: sendErr.message });
130
+ throw sendErr;
131
+ }
132
+
133
+ try {
134
+ await Message.updateOne(
135
+ { _id: message._id },
136
+ { $set: { 'statusInfo.recoveryMessageId': recoveryMessageId } }
137
+ );
138
+ logger.info('[TemplateApprovalJob] Recovered', { originalMessageSid, recoveryMessageId, templateSid });
139
+ } catch (dbErr) {
140
+ logger.error('[TemplateApprovalJob] CRITICAL: send succeeded but DB update failed', {
141
+ messageId: message._id, templateSid, recoveryMessageId, error: dbErr.message
142
+ });
143
+ }
144
+
145
+ provider.deleteTemplate(templateSid).catch((deleteErr) =>
146
+ logger.warn('[TemplateApprovalJob] Failed to delete template after send', { templateSid, error: deleteErr.message })
147
+ );
148
+
149
+ return { success: true, recoveryMessageId };
150
+ }
151
+
152
+ async _handleRejected({ provider, message, templateSid }) {
153
+ await Message.updateOne(
154
+ { _id: message._id },
155
+ { $set: { 'statusInfo.recoveryRejectedAt': new Date() } }
156
+ );
157
+
158
+ try {
159
+ await provider.deleteTemplate(templateSid);
160
+ logger.info('[TemplateApprovalJob] Rejected template deleted', { templateSid, messageId: message._id });
161
+ } catch (deleteErr) {
162
+ logger.warn('[TemplateApprovalJob] Failed to delete rejected template', { templateSid, error: deleteErr.message });
163
+ }
164
+
165
+ return { success: false, reason: 'rejected' };
42
166
  }
43
167
  }
44
168
 
@@ -84,7 +84,8 @@ const messageSchema = new mongoose.Schema({
84
84
  recoveryTemplateSid: { type: String, default: null },
85
85
  recoveryStartedAt: { type: Date, default: null },
86
86
  recoverySentAt: { type: Date, default: null },
87
- recoveryMessageId: { type: String, default: null }
87
+ recoveryMessageId: { type: String, default: null },
88
+ recoveryRejectedAt: { type: Date, default: null }
88
89
  },
89
90
  prompt: { type: Object, default: null },
90
91
  preset: { type: Object, default: null },
@@ -11,7 +11,14 @@ class LocalQueueAdapter extends QueueAdapter {
11
11
  }
12
12
 
13
13
  async enqueue(jobType, payload, options = {}) {
14
- const jobId = `local_${++this.jobCounter}_${Date.now()}`;
14
+ if (options.jobId && this.jobs.has(options.jobId)) {
15
+ const existing = this.jobs.get(options.jobId);
16
+ if (existing.status === 'pending' || existing.status === 'delayed') {
17
+ return existing.id;
18
+ }
19
+ }
20
+
21
+ const jobId = options.jobId || `local_${++this.jobCounter}_${Date.now()}`;
15
22
  const job = {
16
23
  id: jobId,
17
24
  type: jobType,
@@ -21,12 +28,16 @@ class LocalQueueAdapter extends QueueAdapter {
21
28
  result: null,
22
29
  error: null,
23
30
  createdAt: new Date(),
24
- completedAt: null
31
+ completedAt: null,
32
+ timeoutHandle: null
25
33
  };
26
34
 
27
35
  this.jobs.set(jobId, job);
28
36
 
29
- setImmediate(async () => {
37
+ const runHandler = async () => {
38
+ job.timeoutHandle = null;
39
+ if (job.status === 'cancelled') return;
40
+
30
41
  const handler = this.handlers.get(jobType);
31
42
  if (!handler) {
32
43
  job.status = 'failed';
@@ -46,7 +57,14 @@ class LocalQueueAdapter extends QueueAdapter {
46
57
  }
47
58
  this.events.emit(`job:${jobId}`, job);
48
59
  this.jobs.delete(jobId);
49
- });
60
+ };
61
+
62
+ if (options.delay && options.delay > 0) {
63
+ job.status = 'delayed';
64
+ job.timeoutHandle = setTimeout(runHandler, options.delay);
65
+ } else {
66
+ setImmediate(runHandler);
67
+ }
50
68
 
51
69
  return jobId;
52
70
  }
@@ -63,7 +81,11 @@ class LocalQueueAdapter extends QueueAdapter {
63
81
 
64
82
  async cancelJob(jobId) {
65
83
  const job = this.jobs.get(jobId);
66
- if (!job || job.status !== 'pending') return false;
84
+ if (!job || (job.status !== 'pending' && job.status !== 'delayed')) return false;
85
+ if (job.timeoutHandle) {
86
+ clearTimeout(job.timeoutHandle);
87
+ job.timeoutHandle = null;
88
+ }
67
89
  job.status = 'cancelled';
68
90
  this.events.emit(`job:${jobId}`, job);
69
91
  this.jobs.delete(jobId);
@@ -95,6 +117,12 @@ class LocalQueueAdapter extends QueueAdapter {
95
117
  }
96
118
 
97
119
  async shutdown() {
120
+ for (const job of this.jobs.values()) {
121
+ if (job.timeoutHandle) {
122
+ clearTimeout(job.timeoutHandle);
123
+ job.timeoutHandle = null;
124
+ }
125
+ }
98
126
  this.jobs.clear();
99
127
  this.handlers.clear();
100
128
  this.events.removeAllListeners();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "3.15.4",
3
+ "version": "3.15.6",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",