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

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,9 +11,9 @@ 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 { normalizePayload: normalize, registerInteractiveRoute } = require('../../core/interactiveRouteService');
17
17
  const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
18
18
 
19
19
  const { WriteClaimStore } = require('./writeClaimStore');
@@ -44,8 +44,6 @@ let offerClaimStore = null;
44
44
  let tapClaimStore = null;
45
45
  let phoneCheckedFor = null;
46
46
 
47
- const normalize = (value) => String(value ?? '').trim().toLowerCase();
48
-
49
47
  const offerClaims = () => {
50
48
  if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
51
49
  return offerClaimStore;
@@ -96,7 +94,7 @@ async function warnIfPhoneDiffersFromTemplate(contentSid) {
96
94
  async function offerSafetyNet({ code }) {
97
95
  try {
98
96
  const contentSid = getSafetyNetContentSid();
99
- if (!code || !contentSid) return { sent: false, reason: 'not_configured' };
97
+ if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
100
98
  if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
101
99
  if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
102
100
 
@@ -131,7 +129,7 @@ async function markCanWait(code) {
131
129
  const target = requests.find((request) => request.status === ATTENDED_STATUS
132
130
  && request.category === REQUEST_CATEGORY
133
131
  && String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
134
- if (!target) return true;
132
+ if (!target) return false;
135
133
 
136
134
  await updatePatientTask({
137
135
  code,
@@ -148,7 +146,7 @@ async function markCanWait(code) {
148
146
  }
149
147
 
150
148
  async function applyChoice(code, choice) {
151
- if (choice === 'later') return { queued: await markCanWait(code), pledge: null };
149
+ if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
152
150
 
153
151
  const queued = await fileUrgentRequest(code, choice);
154
152
  if (choice !== 'callback') return { queued, pledge: null };
@@ -181,7 +179,9 @@ function ackFor(choice, outcome) {
181
179
  }
182
180
 
183
181
  if (choice === 'later') {
184
- return withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
182
+ return outcome.downgraded
183
+ ? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
184
+ : withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
185
185
  }
186
186
 
187
187
  if (!outcome.pledge) {
@@ -205,8 +205,10 @@ function ackFor(choice, outcome) {
205
205
  async function sendAck(code, choice, outcome) {
206
206
  try {
207
207
  await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
208
+ return true;
208
209
  } catch (error) {
209
210
  logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
211
+ return false;
210
212
  }
211
213
  }
212
214
 
@@ -217,36 +219,42 @@ async function handleSafetyNetTap({ code, payload, title }) {
217
219
  const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
218
220
  if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
219
221
 
222
+ let acknowledged = false;
220
223
  try {
221
224
  if (!await offerClaims().get(code)) {
222
- await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
225
+ acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
223
226
  return { handled: true, choice, queued: false, stale: true };
224
227
  }
225
228
  await offerClaims().set(code, true);
226
229
 
227
- const tapKey = `${code}:${choice}`;
228
- const repeated = Boolean(await tapClaims().get(tapKey));
230
+ const chosen = await tapClaims().get(code);
231
+ const repeated = Boolean(chosen);
229
232
 
230
233
  let outcome = { queued: true, pledge: null, repeated };
231
234
  if (!repeated) {
232
235
  try {
233
236
  outcome = { ...await applyChoice(code, choice), repeated: false };
234
- await tapClaims().set(tapKey, true);
237
+ await tapClaims().set(code, choice);
235
238
  } catch (error) {
236
239
  logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
237
240
  outcome = { queued: false, pledge: null, repeated: false };
238
241
  }
239
242
  }
240
243
 
241
- await sendAck(code, choice, outcome);
244
+ acknowledged = await sendAck(code, choice, outcome);
242
245
  return { handled: true, choice, ...outcome };
243
246
  } finally {
244
- await completeSendClaim(claim.claimId);
247
+ if (acknowledged) await completeSendClaim(claim.claimId);
248
+ else await releaseSendClaim(claim.claimId);
245
249
  }
246
250
  }
247
251
 
248
252
  function registerSafetyNetRoutes() {
249
253
  if (!getSafetyNetContentSid()) return false;
254
+ if (!getSafetyNetPhone()) {
255
+ logger.error('[PausedSafetyNet] A template is configured without a phone number; the safety net stays disarmed');
256
+ return false;
257
+ }
250
258
 
251
259
  registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
252
260
  for (const choice of CHOICES) {
@@ -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.7717",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",