@peopl-health/nexus 5.52.0-dev.7711 → 5.52.0-dev.7718

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.
@@ -4,6 +4,7 @@ const DEFAULT_OFFER_WINDOW_MS = 4 * 60 * 60 * 1000;
4
4
  const DEFAULT_HUMAN_QUIET_MS = 60 * 60 * 1000;
5
5
  const DEFAULT_TAP_WINDOW_MS = 30 * 60 * 1000;
6
6
  const DEFAULT_PLEDGE_MS = 30 * 60 * 1000;
7
+ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
7
8
 
8
9
  const CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
9
10
 
@@ -58,5 +59,6 @@ module.exports = {
58
59
  getOfferWindowMs: () => duration('offerWindowMs', 'PAUSED_SAFETY_NET_WINDOW_MS', DEFAULT_OFFER_WINDOW_MS),
59
60
  getHumanQuietMs: () => duration('humanQuietMs', 'SAFETY_NET_HUMAN_QUIET_MS', DEFAULT_HUMAN_QUIET_MS),
60
61
  getTapWindowMs: () => duration('tapWindowMs', 'SAFETY_NET_TAP_WINDOW_MS', DEFAULT_TAP_WINDOW_MS),
61
- getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS)
62
+ getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS),
63
+ getSweepIntervalMs: () => duration('sweepIntervalMs', 'SAFETY_NET_SWEEP_INTERVAL_MS', DEFAULT_SWEEP_INTERVAL_MS)
62
64
  };
@@ -11,14 +11,15 @@ const {
11
11
  } = require('../config/safetyNetConfig');
12
12
  const { logger } = require('../../utils/logger');
13
13
 
14
- const { acquireSendClaim, completeSendClaim } = require('../../helpers/sendClaimHelper');
14
+ const { acquireSendClaim, completeSendClaim, releaseSendClaim } = require('../../helpers/sendClaimHelper');
15
15
 
16
- const { registerInteractiveRoute } = require('../../core/interactiveRouteService');
16
+ const { clearInteractiveRoutes, normalizePayload: normalize, registerInteractiveRoute } = require('../../core/interactiveRouteService');
17
+ const { getMessages } = require('../../services/messageService');
17
18
  const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
18
19
 
19
20
  const { WriteClaimStore } = require('./writeClaimStore');
20
21
  const { PAUSED_THREAD_DETAILS_PREFIX, recordUnresolvedRequest } = require('./clinicalAirtableService');
21
- const { humanRepliedSince, openPledge } = require('./safetyNetPledgeService');
22
+ const { humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
22
23
 
23
24
  const getMessaging = () => require('../../core/NexusMessaging');
24
25
 
@@ -44,8 +45,6 @@ let offerClaimStore = null;
44
45
  let tapClaimStore = null;
45
46
  let phoneCheckedFor = null;
46
47
 
47
- const normalize = (value) => String(value ?? '').trim().toLowerCase();
48
-
49
48
  const offerClaims = () => {
50
49
  if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
51
50
  return offerClaimStore;
@@ -56,6 +55,11 @@ const tapClaims = () => {
56
55
  return tapClaimStore;
57
56
  };
58
57
 
58
+ const disarmSafetyNet = () => {
59
+ clearInteractiveRoutes();
60
+ stopSafetyNetSweep();
61
+ };
62
+
59
63
  const _resetSafetyNetState = () => {
60
64
  offerClaimStore = null;
61
65
  tapClaimStore = null;
@@ -96,7 +100,7 @@ async function warnIfPhoneDiffersFromTemplate(contentSid) {
96
100
  async function offerSafetyNet({ code }) {
97
101
  try {
98
102
  const contentSid = getSafetyNetContentSid();
99
- if (!code || !contentSid) return { sent: false, reason: 'not_configured' };
103
+ if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
100
104
  if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
101
105
  if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
102
106
 
@@ -114,6 +118,20 @@ async function offerSafetyNet({ code }) {
114
118
  }
115
119
  }
116
120
 
121
+ async function offerIsLive(code) {
122
+ try {
123
+ const since = new Date(Date.now() - getOfferWindowMs());
124
+ const [sent] = await getMessages(
125
+ { numero: code, from_me: true, content_sid: getSafetyNetContentSid(), createdAt: { $gt: since } },
126
+ { sort: { createdAt: -1 }, limit: 1 }
127
+ );
128
+ return Boolean(sent);
129
+ } catch (error) {
130
+ logger.error('[PausedSafetyNet] Could not read the offer history; honouring the tap', { code, error: error.message });
131
+ return true;
132
+ }
133
+ }
134
+
117
135
  async function fileUrgentRequest(code, choice) {
118
136
  const copy = REQUEST_COPY[choice];
119
137
  return recordUnresolvedRequest({
@@ -131,7 +149,7 @@ async function markCanWait(code) {
131
149
  const target = requests.find((request) => request.status === ATTENDED_STATUS
132
150
  && request.category === REQUEST_CATEGORY
133
151
  && String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
134
- if (!target) return true;
152
+ if (!target) return false;
135
153
 
136
154
  await updatePatientTask({
137
155
  code,
@@ -148,7 +166,7 @@ async function markCanWait(code) {
148
166
  }
149
167
 
150
168
  async function applyChoice(code, choice) {
151
- if (choice === 'later') return { queued: await markCanWait(code), pledge: null };
169
+ if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
152
170
 
153
171
  const queued = await fileUrgentRequest(code, choice);
154
172
  if (choice !== 'callback') return { queued, pledge: null };
@@ -181,7 +199,9 @@ function ackFor(choice, outcome) {
181
199
  }
182
200
 
183
201
  if (choice === 'later') {
184
- return withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
202
+ return outcome.downgraded
203
+ ? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
204
+ : withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
185
205
  }
186
206
 
187
207
  if (!outcome.pledge) {
@@ -205,8 +225,10 @@ function ackFor(choice, outcome) {
205
225
  async function sendAck(code, choice, outcome) {
206
226
  try {
207
227
  await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
228
+ return true;
208
229
  } catch (error) {
209
230
  logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
231
+ return false;
210
232
  }
211
233
  }
212
234
 
@@ -217,36 +239,44 @@ async function handleSafetyNetTap({ code, payload, title }) {
217
239
  const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
218
240
  if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
219
241
 
242
+ let acknowledged = false;
220
243
  try {
221
- if (!await offerClaims().get(code)) {
222
- await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
244
+ if (!await offerIsLive(code)) {
245
+ acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
223
246
  return { handled: true, choice, queued: false, stale: true };
224
247
  }
225
248
  await offerClaims().set(code, true);
226
249
 
227
- const tapKey = `${code}:${choice}`;
228
- const repeated = Boolean(await tapClaims().get(tapKey));
250
+ const chosen = await tapClaims().get(code);
251
+ const repeated = Boolean(chosen);
252
+ const effective = repeated ? chosen : choice;
229
253
 
230
254
  let outcome = { queued: true, pledge: null, repeated };
231
255
  if (!repeated) {
232
256
  try {
233
257
  outcome = { ...await applyChoice(code, choice), repeated: false };
234
- await tapClaims().set(tapKey, true);
258
+ await tapClaims().set(code, choice);
235
259
  } catch (error) {
236
260
  logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
237
261
  outcome = { queued: false, pledge: null, repeated: false };
238
262
  }
239
263
  }
240
264
 
241
- await sendAck(code, choice, outcome);
242
- return { handled: true, choice, ...outcome };
265
+ acknowledged = await sendAck(code, effective, outcome);
266
+ return { handled: true, choice: effective, ...outcome };
243
267
  } finally {
244
- await completeSendClaim(claim.claimId);
268
+ if (acknowledged) await completeSendClaim(claim.claimId);
269
+ else await releaseSendClaim(claim.claimId);
245
270
  }
246
271
  }
247
272
 
248
273
  function registerSafetyNetRoutes() {
249
- if (!getSafetyNetContentSid()) return false;
274
+ const armed = Boolean(getSafetyNetContentSid()) && Boolean(getSafetyNetPhone());
275
+ if (!armed) {
276
+ if (getSafetyNetContentSid()) logger.error('[PausedSafetyNet] A template is configured without a phone number; the safety net stays disarmed');
277
+ disarmSafetyNet();
278
+ return false;
279
+ }
250
280
 
251
281
  registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
252
282
  for (const choice of CHOICES) {
@@ -257,6 +287,7 @@ function registerSafetyNetRoutes() {
257
287
  }
258
288
 
259
289
  module.exports = {
290
+ disarmSafetyNet,
260
291
  offerSafetyNet,
261
292
  handleSafetyNetTap,
262
293
  matchSafetyNetChoice,
@@ -1,4 +1,4 @@
1
- const { CONTACT_WINDOW, getPledgeMs, humanReplyFilter } = require('../config/safetyNetConfig');
1
+ const { CONTACT_WINDOW, getPledgeMs, getSweepIntervalMs, humanReplyFilter } = require('../config/safetyNetConfig');
2
2
  const { logger } = require('../../utils/logger');
3
3
  const { clampToHourWindow } = require('../../utils/scheduleUtils');
4
4
 
@@ -91,7 +91,27 @@ async function sweepSafetyNetPledges() {
91
91
  return breached;
92
92
  }
93
93
 
94
+ let sweepTimer = null;
95
+
96
+ function startSafetyNetSweep({ intervalMs = getSweepIntervalMs() } = {}) {
97
+ if (sweepTimer) return sweepTimer;
98
+ sweepTimer = setInterval(() => {
99
+ sweepSafetyNetPledges().catch((error) =>
100
+ logger.error('[SafetyNetPledge] Sweep failed', { error: error.message }));
101
+ }, intervalMs);
102
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
103
+ return sweepTimer;
104
+ }
105
+
106
+ function stopSafetyNetSweep() {
107
+ if (!sweepTimer) return;
108
+ clearInterval(sweepTimer);
109
+ sweepTimer = null;
110
+ }
111
+
94
112
  module.exports = {
113
+ startSafetyNetSweep,
114
+ stopSafetyNetSweep,
95
115
  humanRepliedSince,
96
116
  openPledge,
97
117
  pledgeDueAt,
@@ -7,6 +7,7 @@ const { clearInteractiveRoutes } = require('../core/interactiveRouteService');
7
7
  const { _resetDefaultInstance } = require('../core/NexusMessaging');
8
8
  const { _resetSafetyNetConfig } = require('../clinical/config/safetyNetConfig');
9
9
  const { _resetSafetyNetState } = require('../clinical/services/pausedSafetyNetService');
10
+ const { stopSafetyNetSweep } = require('../clinical/services/safetyNetPledgeService');
10
11
  const { _clearRecentIntakeCache } = require('../clinical/services/clinicalAirtableService');
11
12
  const { _clearExtractorConfigCache } = require('../clinical/services/clinicalExtractionService');
12
13
  const { _resetRegistry } = require('../clinical/services/assistantResolver');
@@ -20,6 +21,7 @@ function resetAll() {
20
21
  clearInteractiveRoutes();
21
22
  _resetSafetyNetConfig();
22
23
  _resetSafetyNetState();
24
+ stopSafetyNetSweep();
23
25
  _resetOverrides();
24
26
  _resetMetaConfig();
25
27
  _clearClinicalContextCache();
@@ -30,7 +30,6 @@ const { releaseHeldMessages, sweepHeldMessages } = require('../services/heldMess
30
30
 
31
31
  const { BatchingManager } = require('../core/BatchingManager');
32
32
  const { ProcessingPipeline } = require('../core/ProcessingPipeline');
33
- const { sweepSafetyNetPledges } = require('../clinical/services/safetyNetPledgeService');
34
33
  const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KIND } = require('../clinical');
35
34
  const { buildOutreachCarrier } = require('../clinical/services/outreachCarrierService');
36
35
  const { routineOutreachCollisionReason } = require('../clinical/services/outreachSweepService');
@@ -171,8 +170,7 @@ class NexusMessaging {
171
170
  ...(config.queueReconciliation || {}),
172
171
  scheduleMessage: (options) => this.scheduledMessageJob.schedule(options),
173
172
  scheduleTemplateApproval: (options) => this.scheduleTemplateApproval(options),
174
- sweepHeldMessages: () => sweepHeldMessages(),
175
- sweepSafetyNetPledges
173
+ sweepHeldMessages: () => sweepHeldMessages()
176
174
  });
177
175
 
178
176
  this._providerStores = false;
@@ -50,6 +50,7 @@ async function invokeInteractiveRoute(messageData) {
50
50
  }
51
51
 
52
52
  module.exports = {
53
+ normalizePayload,
53
54
  registerInteractiveRoute,
54
55
  clearInteractiveRoutes,
55
56
  hasInteractiveRoutes,
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHan
29
29
  const { registerInteractiveRoute, clearInteractiveRoutes } = require('./core/interactiveRouteService');
30
30
  const { configureSafetyNet } = require('./clinical/config/safetyNetConfig');
31
31
  const { registerSafetyNetRoutes } = require('./clinical/services/pausedSafetyNetService');
32
+ const { startSafetyNetSweep, stopSafetyNetSweep } = require('./clinical/services/safetyNetPledgeService');
32
33
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
33
34
  const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
34
35
  const { createWorkflowRunner } = require('./core/workflowRunner');
@@ -189,7 +190,10 @@ class Nexus {
189
190
 
190
191
  try {
191
192
  configureSafetyNet(options.safetyNet || {});
192
- if (registerSafetyNetRoutes()) logger.info('[Nexus] paused-thread safety net armed');
193
+ if (registerSafetyNetRoutes()) {
194
+ startSafetyNetSweep();
195
+ logger.info('[Nexus] paused-thread safety net armed');
196
+ }
193
197
  } catch (e) {
194
198
  logger.warn('Warning: failed to arm the paused-thread safety net:', e?.message || e);
195
199
  }
@@ -248,6 +252,7 @@ class Nexus {
248
252
  }
249
253
 
250
254
  async shutdown({ drainMs, abortGraceMs, teardownMs, closeStorage = true } = {}) {
255
+ stopSafetyNetSweep();
251
256
  if (this._shutdownPromise) return this._shutdownPromise;
252
257
  this._shutdownPromise = (async () => {
253
258
  const result = await this.messaging.shutdown({ drainMs, abortGraceMs, teardownMs });
@@ -13,7 +13,6 @@ class QueueReconciliationService {
13
13
  scheduleMessage,
14
14
  scheduleTemplateApproval,
15
15
  sweepHeldMessages,
16
- sweepSafetyNetPledges,
17
16
  intervalMs = DEFAULT_INTERVAL_MS,
18
17
  lookbackMs = DEFAULT_LOOKBACK_MS,
19
18
  staleAfterMs = DEFAULT_STALE_AFTER_MS,
@@ -22,7 +21,6 @@ class QueueReconciliationService {
22
21
  this.scheduleMessage = scheduleMessage;
23
22
  this.scheduleTemplateApproval = scheduleTemplateApproval;
24
23
  this.sweepHeldMessages = sweepHeldMessages;
25
- this.sweepSafetyNetPledges = sweepSafetyNetPledges;
26
24
  this.intervalMs = intervalMs;
27
25
  this.lookbackMs = lookbackMs;
28
26
  this.staleAfterMs = staleAfterMs;
@@ -62,20 +60,19 @@ class QueueReconciliationService {
62
60
  }
63
61
 
64
62
  async runOnce() {
65
- if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0, breachedPledges: 0 };
63
+ if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0 };
66
64
  const since = new Date(Date.now() - this.lookbackMs);
67
- const [scheduledMessages, recoveries, heldMessages, breachedPledges] = await Promise.all([
65
+ const [scheduledMessages, recoveries, heldMessages] = await Promise.all([
68
66
  this._reEnqueueScheduledMessages(since),
69
67
  this._reArmTemplateApprovals(since),
70
- this._releaseHeldMessages(),
71
- this._resolveSafetyNetPledges()
68
+ this._releaseHeldMessages()
72
69
  ]);
73
70
 
74
- if (scheduledMessages || recoveries || heldMessages || breachedPledges) {
75
- logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages, breachedPledges });
71
+ if (scheduledMessages || recoveries || heldMessages) {
72
+ logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages });
76
73
  }
77
74
 
78
- return { scheduledMessages, recoveries, heldMessages, breachedPledges };
75
+ return { scheduledMessages, recoveries, heldMessages };
79
76
  }
80
77
 
81
78
  async _releaseHeldMessages() {
@@ -88,16 +85,6 @@ class QueueReconciliationService {
88
85
  }
89
86
  }
90
87
 
91
- async _resolveSafetyNetPledges() {
92
- if (typeof this.sweepSafetyNetPledges !== 'function') return 0;
93
- try {
94
- return await this.sweepSafetyNetPledges();
95
- } catch (error) {
96
- logger.error('[QueueReconciliation] Safety-net pledge sweep failed', { error: error.message });
97
- return 0;
98
- }
99
- }
100
-
101
88
  async _reEnqueueScheduledMessages(since) {
102
89
  if (typeof this.scheduleMessage !== 'function') return 0;
103
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.52.0-dev.7711",
3
+ "version": "5.52.0-dev.7718",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",