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

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.
@@ -0,0 +1,62 @@
1
+ const runtimeConfig = require('../../config/runtimeConfig');
2
+
3
+ const DEFAULT_OFFER_WINDOW_MS = 4 * 60 * 60 * 1000;
4
+ const DEFAULT_HUMAN_QUIET_MS = 60 * 60 * 1000;
5
+ const DEFAULT_TAP_WINDOW_MS = 30 * 60 * 1000;
6
+ const DEFAULT_PLEDGE_MS = 30 * 60 * 1000;
7
+
8
+ const CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
9
+
10
+ const SAFETY_NET_SOURCE = 'paused_safety_net';
11
+
12
+ const humanReplyFilter = (code, since) => ({
13
+ numero: code,
14
+ from_me: true,
15
+ content_sid: null,
16
+ origin: { $ne: 'assistant' },
17
+ triggeredBy: { $ne: SAFETY_NET_SOURCE },
18
+ createdAt: { $gt: since }
19
+ });
20
+
21
+ const CHOICE_LABEL_KEYS = {
22
+ urgent: 'PAUSED_SAFETY_NET_URGENT_LABEL',
23
+ callback: 'PAUSED_SAFETY_NET_CALLBACK_LABEL',
24
+ later: 'PAUSED_SAFETY_NET_LATER_LABEL'
25
+ };
26
+
27
+ let injected = {};
28
+
29
+ const configureSafetyNet = (config = {}) => {
30
+ injected = config && typeof config === 'object' ? config : {};
31
+ };
32
+
33
+ const _resetSafetyNetConfig = () => {
34
+ injected = {};
35
+ };
36
+
37
+ const text = (key, envKey) => {
38
+ const raw = injected[key] ?? runtimeConfig.get(envKey);
39
+ const value = String(raw ?? '').trim();
40
+ return value || null;
41
+ };
42
+
43
+ const duration = (key, envKey, fallback) => {
44
+ const raw = Number(injected[key] ?? runtimeConfig.get(envKey));
45
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
46
+ };
47
+
48
+ module.exports = {
49
+ CONTACT_WINDOW,
50
+ SAFETY_NET_SOURCE,
51
+ humanReplyFilter,
52
+ CHOICE_LABEL_KEYS,
53
+ configureSafetyNet,
54
+ _resetSafetyNetConfig,
55
+ getSafetyNetContentSid: () => text('contentSid', 'PAUSED_SAFETY_NET_CONTENT_SID'),
56
+ getSafetyNetPhone: () => text('phone', 'PAUSED_SAFETY_NET_PHONE'),
57
+ getSafetyNetLabel: (choice) => text(`${choice}Label`, CHOICE_LABEL_KEYS[choice]),
58
+ getOfferWindowMs: () => duration('offerWindowMs', 'PAUSED_SAFETY_NET_WINDOW_MS', DEFAULT_OFFER_WINDOW_MS),
59
+ getHumanQuietMs: () => duration('humanQuietMs', 'SAFETY_NET_HUMAN_QUIET_MS', DEFAULT_HUMAN_QUIET_MS),
60
+ getTapWindowMs: () => duration('tapWindowMs', 'SAFETY_NET_TAP_WINDOW_MS', DEFAULT_TAP_WINDOW_MS),
61
+ getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS)
62
+ };
@@ -16,9 +16,19 @@ const { getMessages, countMessages } = require('../../services/messageService');
16
16
 
17
17
  const { getAssistantById } = require('./assistantResolver');
18
18
  const { recordPausedThreadMessage } = require('./clinicalAirtableService');
19
+ const { offerSafetyNet } = require('./pausedSafetyNetService');
19
20
 
20
21
  const MEDIA_BACKLOG_LIMIT = 10;
21
22
 
23
+ const pausedAlertText = async (code, newest) => {
24
+ if (!newest.interactive_type) return newest.plainBody || newest.body;
25
+ const [written] = await getMessages(
26
+ { numero: code, from_me: false, interactive_type: null, createdAt: { $lte: newest.createdAt } },
27
+ { sort: { createdAt: -1 }, limit: 1 }
28
+ );
29
+ return written ? written.plainBody || written.body : null;
30
+ };
31
+
22
32
  const fetchMediaBacklog = async (code, trigger) => {
23
33
  const [lastOutbound] = await getMessages(
24
34
  { numero: code, from_me: true, createdAt: { $lt: trigger.createdAt } },
@@ -112,7 +122,12 @@ const preProcessMessagesCore = async (code, message_ = null, thread) => {
112
122
  return { shouldProcess: false, messages: null, timings, pendingMediaUpdates: [] };
113
123
  }
114
124
 
115
- if (thread.stopped) await recordPausedThreadMessage({ code, text: lastMessage[0].plainBody || lastMessage[0].body });
125
+ if (thread.stopped) {
126
+ await Promise.allSettled([
127
+ recordPausedThreadMessage({ code, text: await pausedAlertText(code, lastMessage[0]) }),
128
+ offerSafetyNet({ code })
129
+ ]);
130
+ }
116
131
 
117
132
  const backlog = await fetchMediaBacklog(code, lastMessage[0]);
118
133
  const replies = [...backlog, ...lastMessage];
@@ -14,6 +14,7 @@ const recentIntakeCache = new WriteClaimStore({ namespace: 'intake', ttlMs: INTA
14
14
  const EMERGENCY_MERGE_WINDOW_MS = 5 * 60 * 1000;
15
15
  const EMERGENCY_URGENCY_RANK = { routine: 0, urgent: 1, asap: 2, stat: 3 };
16
16
  const recentEmergencyCache = new WriteClaimStore({ namespace: 'emergency', ttlMs: EMERGENCY_MERGE_WINDOW_MS, maxSize: 500 });
17
+ const PAUSED_THREAD_DETAILS_PREFIX = 'Bot en pausa:';
17
18
  const PAUSED_ALERT_WINDOW_MS = 4 * 60 * 60 * 1000;
18
19
  const recentPausedAlertCache = new WriteClaimStore({ namespace: 'paused-alert', ttlMs: PAUSED_ALERT_WINDOW_MS, maxSize: 1000 });
19
20
  const UNRESOLVED_WINDOW_MS = 30 * 60 * 1000;
@@ -119,7 +120,7 @@ async function recordPausedThreadMessage({ code, text }) {
119
120
  code,
120
121
  trace: null,
121
122
  question: text || 'El paciente envió un archivo sin texto.',
122
- details: `Bot en pausa: el paciente ${code} escribió y nadie ha respondido. Revisar la conversación.`,
123
+ details: `${PAUSED_THREAD_DETAILS_PREFIX} el paciente ${code} escribió y nadie ha respondido. Revisar la conversación.`,
123
124
  category: 'contact_team_member',
124
125
  urgency: 'soon',
125
126
  });
@@ -501,6 +502,7 @@ async function recordClusterRecommendationsMirror({ code, triageRecordId, step1
501
502
  }
502
503
 
503
504
  module.exports = {
505
+ PAUSED_THREAD_DETAILS_PREFIX,
504
506
  recordUnresolvedRequest,
505
507
  recordPausedThreadMessage,
506
508
  recordEmergency,
@@ -0,0 +1,267 @@
1
+ const {
2
+ CONTACT_WINDOW,
3
+ SAFETY_NET_SOURCE,
4
+ getHumanQuietMs,
5
+ getOfferWindowMs,
6
+ getPledgeMs,
7
+ getSafetyNetContentSid,
8
+ getSafetyNetLabel,
9
+ getSafetyNetPhone,
10
+ getTapWindowMs
11
+ } = require('../config/safetyNetConfig');
12
+ const { logger } = require('../../utils/logger');
13
+
14
+ const { acquireSendClaim, completeSendClaim } = require('../../helpers/sendClaimHelper');
15
+
16
+ const { registerInteractiveRoute } = require('../../core/interactiveRouteService');
17
+ const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
18
+
19
+ const { WriteClaimStore } = require('./writeClaimStore');
20
+ const { PAUSED_THREAD_DETAILS_PREFIX, recordUnresolvedRequest } = require('./clinicalAirtableService');
21
+ const { humanRepliedSince, openPledge } = require('./safetyNetPledgeService');
22
+
23
+ const getMessaging = () => require('../../core/NexusMessaging');
24
+
25
+ const SOURCE = SAFETY_NET_SOURCE;
26
+ const PAYLOAD_PREFIX = 'peopl_safetynet_';
27
+ const CHOICES = ['urgent', 'callback', 'later'];
28
+ const REQUEST_CATEGORY = 'contact_team_member';
29
+ const CAN_WAIT_NOTE = 'El paciente indicó que su solicitud puede esperar.';
30
+ const ATTENDED_STATUS = 'sin atender';
31
+
32
+ const REQUEST_COPY = {
33
+ urgent: {
34
+ question: 'El paciente indicó que su situación es urgente',
35
+ details: (code) => `El paciente ${code} recibió el aviso de bot en pausa y eligió "urgencia". Se le dio el número para llamar. Revisar la conversación ahora.`
36
+ },
37
+ callback: {
38
+ question: 'El paciente pidió que el equipo lo contacte hoy',
39
+ details: (code) => `El paciente ${code} recibió el aviso de bot en pausa y pidió que lo contacten. Se le prometió contacto; revisar la conversación.`
40
+ }
41
+ };
42
+
43
+ let offerClaimStore = null;
44
+ let tapClaimStore = null;
45
+ let phoneCheckedFor = null;
46
+
47
+ const normalize = (value) => String(value ?? '').trim().toLowerCase();
48
+
49
+ const offerClaims = () => {
50
+ if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
51
+ return offerClaimStore;
52
+ };
53
+
54
+ const tapClaims = () => {
55
+ if (!tapClaimStore) tapClaimStore = new WriteClaimStore({ namespace: 'safety-net-choice', ttlMs: getTapWindowMs() });
56
+ return tapClaimStore;
57
+ };
58
+
59
+ const _resetSafetyNetState = () => {
60
+ offerClaimStore = null;
61
+ tapClaimStore = null;
62
+ phoneCheckedFor = null;
63
+ };
64
+
65
+ function matchSafetyNetChoice(payload, title) {
66
+ const candidate = normalize(payload);
67
+ if (candidate.startsWith(PAYLOAD_PREFIX)) {
68
+ const suffix = candidate.slice(PAYLOAD_PREFIX.length);
69
+ return CHOICES.find((choice) => choice === suffix) || null;
70
+ }
71
+
72
+ const label = normalize(title) || candidate;
73
+ if (!label) return null;
74
+ return CHOICES.find((choice) => {
75
+ const configured = normalize(getSafetyNetLabel(choice));
76
+ return configured && configured === label;
77
+ }) || null;
78
+ }
79
+
80
+ async function warnIfPhoneDiffersFromTemplate(contentSid) {
81
+ const phone = getSafetyNetPhone();
82
+ if (!phone || phoneCheckedFor === contentSid) return;
83
+ phoneCheckedFor = contentSid;
84
+ try {
85
+ const body = await getMessaging().requireProvider().renderTemplate(contentSid);
86
+ const digits = (value) => String(value ?? '').replace(/\D/g, '');
87
+ if (body && !digits(body).includes(digits(phone))) {
88
+ logger.error('[PausedSafetyNet] The configured phone number is absent from the template body', { contentSid });
89
+ }
90
+ } catch (error) {
91
+ phoneCheckedFor = null;
92
+ logger.warn('[PausedSafetyNet] Could not read the template to check the phone number', { contentSid, error: error.message });
93
+ }
94
+ }
95
+
96
+ async function offerSafetyNet({ code }) {
97
+ try {
98
+ const contentSid = getSafetyNetContentSid();
99
+ if (!code || !contentSid) return { sent: false, reason: 'not_configured' };
100
+ if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
101
+ if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
102
+
103
+ await warnIfPhoneDiffersFromTemplate(contentSid);
104
+
105
+ const result = await getMessaging().sendMessage({ code, contentSid, triggeredBy: SOURCE });
106
+ const sent = result?.success === true || result?.completed === true;
107
+ if (!sent) return { sent: false, reason: result?.status || 'send_failed' };
108
+
109
+ await offerClaims().set(code, true);
110
+ return { sent: true, reason: null };
111
+ } catch (error) {
112
+ logger.error('[PausedSafetyNet] Could not offer the safety net', { code, error: error.message });
113
+ return { sent: false, reason: 'error' };
114
+ }
115
+ }
116
+
117
+ async function fileUrgentRequest(code, choice) {
118
+ const copy = REQUEST_COPY[choice];
119
+ return recordUnresolvedRequest({
120
+ code,
121
+ trace: null,
122
+ question: copy.question,
123
+ details: copy.details(code),
124
+ category: REQUEST_CATEGORY,
125
+ urgency: 'urgent'
126
+ });
127
+ }
128
+
129
+ async function markCanWait(code) {
130
+ const { requests = [] } = await getPatientTasks(code);
131
+ const target = requests.find((request) => request.status === ATTENDED_STATUS
132
+ && request.category === REQUEST_CATEGORY
133
+ && String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
134
+ if (!target) return true;
135
+
136
+ await updatePatientTask({
137
+ code,
138
+ kind: 'requests',
139
+ recordId: target.recordId,
140
+ changes: {
141
+ urgency: 'routine',
142
+ note: [target.note, CAN_WAIT_NOTE].filter(Boolean).join('\n')
143
+ },
144
+ updatedBy: SOURCE,
145
+ source: SOURCE
146
+ });
147
+ return true;
148
+ }
149
+
150
+ async function applyChoice(code, choice) {
151
+ if (choice === 'later') return { queued: await markCanWait(code), pledge: null };
152
+
153
+ const queued = await fileUrgentRequest(code, choice);
154
+ if (choice !== 'callback') return { queued, pledge: null };
155
+ if (!queued) return { queued, pledge: null };
156
+
157
+ try {
158
+ return { queued, pledge: await openPledge({ code }) };
159
+ } catch (error) {
160
+ logger.error('[PausedSafetyNet] The request was filed but no pledge was opened', { code, error: error.message });
161
+ return { queued, pledge: null };
162
+ }
163
+ }
164
+
165
+ const withPhone = (text, closing) => {
166
+ const phone = getSafetyNetPhone();
167
+ return phone ? `${text} ${closing.replace('{phone}', phone)}` : text;
168
+ };
169
+
170
+ function ackFor(choice, outcome) {
171
+ if (outcome.stale) {
172
+ return withPhone('Recibimos tu respuesta y el equipo va a revisar tu conversación.', 'Si necesitas ayuda ahora, llámanos al {phone}.');
173
+ }
174
+
175
+ if (!outcome.queued) {
176
+ return withPhone('No pudimos registrar tu solicitud.', 'Por favor llámanos al {phone} para que te atendamos.');
177
+ }
178
+
179
+ if (choice === 'urgent') {
180
+ return withPhone('Entendido, ya avisamos al equipo.', 'Si es una urgencia médica, llámanos ahora al {phone}.');
181
+ }
182
+
183
+ if (choice === 'later') {
184
+ return withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
185
+ }
186
+
187
+ if (!outcome.pledge) {
188
+ return withPhone('Listo, ya avisamos al equipo y te van a contactar.', 'Si te sientes peor antes, llámanos al {phone}.');
189
+ }
190
+
191
+ if (outcome.pledge.deferredToWindow) {
192
+ return withPhone(
193
+ `Listo, ya avisamos al equipo. Te van a contactar en cuanto abran, a partir de las ${CONTACT_WINDOW.startHour}:00.`,
194
+ 'Si es una urgencia médica, llámanos ahora al {phone}.'
195
+ );
196
+ }
197
+
198
+ const minutes = Math.round(getPledgeMs() / 60000);
199
+ return withPhone(
200
+ `Listo. Un miembro del equipo te va a contactar en los próximos ${minutes} minutos.`,
201
+ 'Si te sientes peor antes, llámanos al {phone}.'
202
+ );
203
+ }
204
+
205
+ async function sendAck(code, choice, outcome) {
206
+ try {
207
+ await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
208
+ } catch (error) {
209
+ logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
210
+ }
211
+ }
212
+
213
+ async function handleSafetyNetTap({ code, payload, title }) {
214
+ const choice = matchSafetyNetChoice(payload, title);
215
+ if (!choice || !code) return { handled: false };
216
+
217
+ const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
218
+ if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
219
+
220
+ try {
221
+ if (!await offerClaims().get(code)) {
222
+ await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
223
+ return { handled: true, choice, queued: false, stale: true };
224
+ }
225
+ await offerClaims().set(code, true);
226
+
227
+ const tapKey = `${code}:${choice}`;
228
+ const repeated = Boolean(await tapClaims().get(tapKey));
229
+
230
+ let outcome = { queued: true, pledge: null, repeated };
231
+ if (!repeated) {
232
+ try {
233
+ outcome = { ...await applyChoice(code, choice), repeated: false };
234
+ await tapClaims().set(tapKey, true);
235
+ } catch (error) {
236
+ logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
237
+ outcome = { queued: false, pledge: null, repeated: false };
238
+ }
239
+ }
240
+
241
+ await sendAck(code, choice, outcome);
242
+ return { handled: true, choice, ...outcome };
243
+ } finally {
244
+ await completeSendClaim(claim.claimId);
245
+ }
246
+ }
247
+
248
+ function registerSafetyNetRoutes() {
249
+ if (!getSafetyNetContentSid()) return false;
250
+
251
+ registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
252
+ for (const choice of CHOICES) {
253
+ const label = getSafetyNetLabel(choice);
254
+ if (label) registerInteractiveRoute(label, handleSafetyNetTap);
255
+ }
256
+ return true;
257
+ }
258
+
259
+ module.exports = {
260
+ offerSafetyNet,
261
+ handleSafetyNetTap,
262
+ matchSafetyNetChoice,
263
+ registerSafetyNetRoutes,
264
+ _resetSafetyNetState,
265
+ PAYLOAD_PREFIX,
266
+ CHOICES
267
+ };
@@ -0,0 +1,100 @@
1
+ const { CONTACT_WINDOW, getPledgeMs, humanReplyFilter } = require('../config/safetyNetConfig');
2
+ const { logger } = require('../../utils/logger');
3
+ const { clampToHourWindow } = require('../../utils/scheduleUtils');
4
+
5
+ const { SafetyNetPledge } = require('../../models/safetyNetPledgeModel');
6
+
7
+ const { getMessages } = require('../../services/messageService');
8
+
9
+ const { recordUnresolvedRequest } = require('./clinicalAirtableService');
10
+
11
+ const SWEEP_LIMIT = 200;
12
+ const BREACH_CATEGORY = 'contact_team_member';
13
+ const BREACH_QUESTION = 'El paciente sigue esperando el contacto que se le prometió';
14
+
15
+ async function humanRepliedSince(code, since) {
16
+ const [reply] = await getMessages(humanReplyFilter(code, since), { sort: { createdAt: -1 }, limit: 1 });
17
+ return Boolean(reply);
18
+ }
19
+
20
+ function pledgeDueAt(pledgedAt) {
21
+ const target = new Date(pledgedAt.getTime() + getPledgeMs());
22
+ const opened = clampToHourWindow(target, CONTACT_WINDOW);
23
+ if (!opened) return { dueAt: target, deferredToWindow: false };
24
+ return { dueAt: opened, deferredToWindow: opened.getTime() > target.getTime() };
25
+ }
26
+
27
+ async function openPledge({ code }) {
28
+ const pledgedAt = new Date();
29
+ const { dueAt, deferredToWindow } = pledgeDueAt(pledgedAt);
30
+ const pledge = await SafetyNetPledge.create({ code, pledgedAt, dueAt, deferredToWindow });
31
+ logger.info('[SafetyNetPledge] Contact pledged', { code, dueAt, deferredToWindow });
32
+ return { pledgeId: pledge._id, dueAt, deferredToWindow };
33
+ }
34
+
35
+ async function reportBreach(pledge) {
36
+ const waitedMinutes = Math.round((Date.now() - new Date(pledge.pledgedAt).getTime()) / 60000);
37
+ return recordUnresolvedRequest({
38
+ code: pledge.code,
39
+ trace: null,
40
+ question: BREACH_QUESTION,
41
+ details: `El paciente ${pledge.code} pidió que lo contactaran y lleva ${waitedMinutes} minutos sin respuesta. Revisar la conversación ahora.`,
42
+ category: BREACH_CATEGORY,
43
+ urgency: 'urgent'
44
+ });
45
+ }
46
+
47
+ const REPORT_LEASE_MS = 5 * 60 * 1000;
48
+
49
+ const settle = async (id, from, state) => Boolean(
50
+ await SafetyNetPledge.findOneAndUpdate({ _id: id, state: from }, { $set: { state, resolvedAt: new Date() } }).lean()
51
+ );
52
+
53
+ const takeReportingLease = async (id) => Boolean(
54
+ await SafetyNetPledge.findOneAndUpdate(
55
+ { _id: id, $or: [{ state: 'pending' }, { state: 'reporting', reportingSince: { $lt: new Date(Date.now() - REPORT_LEASE_MS) } }] },
56
+ { $set: { state: 'reporting', reportingSince: new Date() } },
57
+ ).lean()
58
+ );
59
+
60
+ async function sweepSafetyNetPledges() {
61
+ const due = await SafetyNetPledge.find({
62
+ state: { $in: ['pending', 'reporting'] },
63
+ dueAt: { $lte: new Date() },
64
+ })
65
+ .select({ _id: 1, code: 1, pledgedAt: 1 })
66
+ .limit(SWEEP_LIMIT)
67
+ .lean();
68
+
69
+ let breached = 0;
70
+
71
+ for (const pledge of due) {
72
+ try {
73
+ if (!await takeReportingLease(pledge._id)) continue;
74
+
75
+ if (await humanRepliedSince(pledge.code, pledge.pledgedAt)) {
76
+ await settle(pledge._id, 'reporting', 'met');
77
+ continue;
78
+ }
79
+
80
+ if (!await reportBreach(pledge)) {
81
+ await SafetyNetPledge.updateOne({ _id: pledge._id, state: 'reporting' }, { $set: { state: 'pending', reportingSince: null } });
82
+ logger.error('[SafetyNetPledge] Breach was not recorded, pledge returns to pending for the next sweep', { code: pledge.code });
83
+ continue;
84
+ }
85
+ if (await settle(pledge._id, 'reporting', 'breached')) breached += 1;
86
+ } catch (error) {
87
+ logger.error('[SafetyNetPledge] Could not resolve a pledge', { code: pledge.code, error: error.message });
88
+ }
89
+ }
90
+
91
+ return breached;
92
+ }
93
+
94
+ module.exports = {
95
+ humanRepliedSince,
96
+ openPledge,
97
+ pledgeDueAt,
98
+ sweepSafetyNetPledges,
99
+ BREACH_QUESTION
100
+ };
@@ -3,7 +3,10 @@ const { _resetMetaConfig } = require('../config/metaConfig');
3
3
  const { _clearClinicalContextCache } = require('../helpers/patientInformationHelper');
4
4
  const { setPreprocessingHandler } = require('../services/preprocessingService');
5
5
  const { clearTriageHistoryCache } = require('../services/triageHistoryService');
6
+ const { clearInteractiveRoutes } = require('../core/interactiveRouteService');
6
7
  const { _resetDefaultInstance } = require('../core/NexusMessaging');
8
+ const { _resetSafetyNetConfig } = require('../clinical/config/safetyNetConfig');
9
+ const { _resetSafetyNetState } = require('../clinical/services/pausedSafetyNetService');
7
10
  const { _clearRecentIntakeCache } = require('../clinical/services/clinicalAirtableService');
8
11
  const { _clearExtractorConfigCache } = require('../clinical/services/clinicalExtractionService');
9
12
  const { _resetRegistry } = require('../clinical/services/assistantResolver');
@@ -14,6 +17,9 @@ function resetAll() {
14
17
  _resetRegistry();
15
18
  _resetLLMState();
16
19
  setPreprocessingHandler(null);
20
+ clearInteractiveRoutes();
21
+ _resetSafetyNetConfig();
22
+ _resetSafetyNetState();
17
23
  _resetOverrides();
18
24
  _resetMetaConfig();
19
25
  _clearClinicalContextCache();
@@ -30,6 +30,7 @@ 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');
33
34
  const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KIND } = require('../clinical');
34
35
  const { buildOutreachCarrier } = require('../clinical/services/outreachCarrierService');
35
36
  const { routineOutreachCollisionReason } = require('../clinical/services/outreachSweepService');
@@ -45,6 +46,7 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
45
46
  const { createQueueAdapter } = require('../queue');
46
47
  const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
47
48
  const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
49
+ const { invokeInteractiveRoute } = require('./interactiveRouteService');
48
50
 
49
51
  const { PhiProcessor } = require('./PhiProcessor');
50
52
 
@@ -169,7 +171,8 @@ class NexusMessaging {
169
171
  ...(config.queueReconciliation || {}),
170
172
  scheduleMessage: (options) => this.scheduledMessageJob.schedule(options),
171
173
  scheduleTemplateApproval: (options) => this.scheduleTemplateApproval(options),
172
- sweepHeldMessages: () => sweepHeldMessages()
174
+ sweepHeldMessages: () => sweepHeldMessages(),
175
+ sweepSafetyNetPledges
173
176
  });
174
177
 
175
178
  this._providerStores = false;
@@ -580,6 +583,7 @@ class NexusMessaging {
580
583
  }
581
584
 
582
585
  async handleInteractive(messageData) {
586
+ await invokeInteractiveRoute(messageData);
583
587
  if (this.handlers.onInteractive) return await this.handlers.onInteractive(messageData, this);
584
588
  }
585
589
 
@@ -0,0 +1,58 @@
1
+ const { logger } = require('../utils/logger');
2
+
3
+ const routes = new Map();
4
+
5
+ const normalizePayload = (value) => String(value ?? '').trim().toLowerCase();
6
+
7
+ function registerInteractiveRoute(prefix, handler) {
8
+ const key = normalizePayload(prefix);
9
+ if (!key) throw new Error('registerInteractiveRoute requires a payload prefix');
10
+ if (typeof handler !== 'function') throw new Error('registerInteractiveRoute requires a handler function');
11
+ routes.set(key, handler);
12
+ }
13
+
14
+ function clearInteractiveRoutes() {
15
+ routes.clear();
16
+ }
17
+
18
+ function hasInteractiveRoutes() {
19
+ return routes.size > 0;
20
+ }
21
+
22
+ function resolveInteractiveRoute(payload) {
23
+ const candidate = normalizePayload(payload);
24
+ if (!candidate) return null;
25
+ for (const [prefix, handler] of routes) {
26
+ if (candidate.startsWith(prefix)) return handler;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ async function invokeInteractiveRoute(messageData) {
32
+ if (!routes.size) return false;
33
+
34
+ const interactive = messageData?.interactive || {};
35
+ const handler = resolveInteractiveRoute(interactive.payload);
36
+ if (!handler) return false;
37
+
38
+ try {
39
+ await handler({
40
+ code: messageData.code,
41
+ payload: interactive.payload ?? null,
42
+ title: interactive.title ?? null,
43
+ messageData
44
+ });
45
+ return true;
46
+ } catch (error) {
47
+ logger.error('[InteractiveRoutes] Route handler failed', { error: error?.message || error });
48
+ return false;
49
+ }
50
+ }
51
+
52
+ module.exports = {
53
+ registerInteractiveRoute,
54
+ clearInteractiveRoutes,
55
+ hasInteractiveRoutes,
56
+ resolveInteractiveRoute,
57
+ invokeInteractiveRoute
58
+ };
@@ -14,6 +14,7 @@ const ATTENTION_OPERATIONS_STATUS = 'pendiente';
14
14
  const ATTENTION_CASE_WINDOW_DAYS = 14;
15
15
  const ATTENTION_CASE_WINDOW_MS = ATTENTION_CASE_WINDOW_DAYS * 24 * 60 * 60 * 1000;
16
16
  const URGENT_REQUEST_URGENCY = 'urgent';
17
+ const REQUEST_URGENCIES = ['routine', 'soon', 'urgent'];
17
18
  const URGENT_CASE_TYPES = ['Urgencia', 'Emergencia'];
18
19
 
19
20
  const isRequestOpen = (status) => !CLOSED_REQUEST_STATUSES.includes(status);
@@ -40,6 +41,7 @@ module.exports = {
40
41
  ATTENTION_CASE_WINDOW_DAYS,
41
42
  ATTENTION_OPERATIONS_STATUS,
42
43
  ATTENTION_REQUEST_STATUS,
44
+ REQUEST_URGENCIES,
43
45
  isCaseOpen,
44
46
  isCaseUrgent,
45
47
  isRequestOpen,
package/lib/index.d.ts CHANGED
@@ -22,8 +22,14 @@ declare module '@peopl-health/nexus' {
22
22
  type?: 'message' | 'interactive' | 'media' | 'command' | 'keyword' | 'flow' | 'empty';
23
23
  }
24
24
 
25
+ export function registerInteractiveRoute(
26
+ prefix: string,
27
+ handler: (tap: { code: string; payload: string | null; title: string | null; messageData: unknown }) => unknown
28
+ ): void;
29
+ export function clearInteractiveRoutes(): void;
30
+
25
31
  export interface InteractiveData {
26
- type: 'button' | 'list' | 'flow';
32
+ type: 'button' | 'list' | 'flow' | 'quick_reply';
27
33
  payload?: string;
28
34
  title?: string;
29
35
  description?: string;
package/lib/index.js CHANGED
@@ -26,6 +26,9 @@ const { setModelDatabases, setModelDatabase, getModelDatabase, connect: mongoCon
26
26
  const { TwilioProvider } = require('./adapters/TwilioProvider');
27
27
  const { BaileysProvider } = require('./adapters/BaileysProvider');
28
28
  const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHandler } = require('./services/preprocessingService');
29
+ const { registerInteractiveRoute, clearInteractiveRoutes } = require('./core/interactiveRouteService');
30
+ const { configureSafetyNet } = require('./clinical/config/safetyNetConfig');
31
+ const { registerSafetyNetRoutes } = require('./clinical/services/pausedSafetyNetService');
29
32
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
30
33
  const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
31
34
  const { createWorkflowRunner } = require('./core/workflowRunner');
@@ -184,6 +187,13 @@ class Nexus {
184
187
  logger.warn('Warning: failed to register clinical tools:', e?.message || e);
185
188
  }
186
189
 
190
+ try {
191
+ configureSafetyNet(options.safetyNet || {});
192
+ if (registerSafetyNetRoutes()) logger.info('[Nexus] paused-thread safety net armed');
193
+ } catch (e) {
194
+ logger.warn('Warning: failed to arm the paused-thread safety net:', e?.message || e);
195
+ }
196
+
187
197
  await this.messaging.initializeLlmMonitor();
188
198
 
189
199
  this.isInitialized = true;
@@ -280,6 +290,8 @@ module.exports = {
280
290
  setPreprocessingHandler,
281
291
  hasPreprocessingHandler,
282
292
  invokePreprocessingHandler,
293
+ registerInteractiveRoute,
294
+ clearInteractiveRoutes,
283
295
  routes,
284
296
  syncClinicalContext,
285
297
  runProactiveOutreachSweep,
@@ -0,0 +1,20 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const PLEDGE_STATES = ['pending', 'reporting', 'met', 'breached'];
4
+
5
+ const safetyNetPledgeSchema = new mongoose.Schema({
6
+ code: { type: String, required: true },
7
+ pledgedAt: { type: Date, required: true },
8
+ dueAt: { type: Date, required: true },
9
+ deferredToWindow: { type: Boolean, default: false },
10
+ state: { type: String, enum: PLEDGE_STATES, default: 'pending' },
11
+ reportingSince: { type: Date, default: null },
12
+ resolvedAt: { type: Date, default: null },
13
+ createdAt: { type: Date, default: Date.now }
14
+ });
15
+
16
+ safetyNetPledgeSchema.index({ state: 1, dueAt: 1 }, { name: 'safety_net_pledge_due_idx' });
17
+
18
+ const SafetyNetPledge = mongoose.model('SafetyNetPledge', safetyNetPledgeSchema);
19
+
20
+ module.exports = { SafetyNetPledge, PLEDGE_STATES };
@@ -4,6 +4,7 @@ const { httpError } = require('../utils/httpErrorUtils');
4
4
 
5
5
  const { escapeFormulaValue, fieldEquals, recordIdIn } = require('../helpers/airtableFormulaHelper');
6
6
  const {
7
+ REQUEST_URGENCIES,
7
8
  isCaseOpen,
8
9
  isCaseUrgent,
9
10
  isRequestOpen,
@@ -109,6 +110,7 @@ const TASK_KINDS = {
109
110
  toTask: toRequest,
110
111
  writable: {
111
112
  status: { field: 'status', options: REQUEST_STATUSES },
113
+ urgency: { field: 'urgency', options: REQUEST_URGENCIES },
112
114
  note: { field: 'Observaciones' },
113
115
  },
114
116
  },
@@ -170,7 +172,7 @@ async function getPatientTasks(code) {
170
172
  requests: requests.attentionCount,
171
173
  isUrgent: requests.isUrgent || cases.isUrgent,
172
174
  },
173
- options: { requestStatus: REQUEST_STATUSES, caseStatus: CASE_STATUSES, operationsStatus: OPERATIONS_STATUSES },
175
+ options: { requestStatus: REQUEST_STATUSES, requestUrgency: REQUEST_URGENCIES, caseStatus: CASE_STATUSES, operationsStatus: OPERATIONS_STATUSES },
174
176
  };
175
177
  }
176
178
 
@@ -13,6 +13,7 @@ class QueueReconciliationService {
13
13
  scheduleMessage,
14
14
  scheduleTemplateApproval,
15
15
  sweepHeldMessages,
16
+ sweepSafetyNetPledges,
16
17
  intervalMs = DEFAULT_INTERVAL_MS,
17
18
  lookbackMs = DEFAULT_LOOKBACK_MS,
18
19
  staleAfterMs = DEFAULT_STALE_AFTER_MS,
@@ -21,6 +22,7 @@ class QueueReconciliationService {
21
22
  this.scheduleMessage = scheduleMessage;
22
23
  this.scheduleTemplateApproval = scheduleTemplateApproval;
23
24
  this.sweepHeldMessages = sweepHeldMessages;
25
+ this.sweepSafetyNetPledges = sweepSafetyNetPledges;
24
26
  this.intervalMs = intervalMs;
25
27
  this.lookbackMs = lookbackMs;
26
28
  this.staleAfterMs = staleAfterMs;
@@ -60,19 +62,20 @@ class QueueReconciliationService {
60
62
  }
61
63
 
62
64
  async runOnce() {
63
- if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0 };
65
+ if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0, breachedPledges: 0 };
64
66
  const since = new Date(Date.now() - this.lookbackMs);
65
- const [scheduledMessages, recoveries, heldMessages] = await Promise.all([
67
+ const [scheduledMessages, recoveries, heldMessages, breachedPledges] = await Promise.all([
66
68
  this._reEnqueueScheduledMessages(since),
67
69
  this._reArmTemplateApprovals(since),
68
- this._releaseHeldMessages()
70
+ this._releaseHeldMessages(),
71
+ this._resolveSafetyNetPledges()
69
72
  ]);
70
73
 
71
- if (scheduledMessages || recoveries || heldMessages) {
72
- logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages });
74
+ if (scheduledMessages || recoveries || heldMessages || breachedPledges) {
75
+ logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages, breachedPledges });
73
76
  }
74
77
 
75
- return { scheduledMessages, recoveries, heldMessages };
78
+ return { scheduledMessages, recoveries, heldMessages, breachedPledges };
76
79
  }
77
80
 
78
81
  async _releaseHeldMessages() {
@@ -85,6 +88,16 @@ class QueueReconciliationService {
85
88
  }
86
89
  }
87
90
 
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
+
88
101
  async _reEnqueueScheduledMessages(since) {
89
102
  if (typeof this.scheduleMessage !== 'function') return 0;
90
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.52.0-dev.7696",
3
+ "version": "5.52.0-dev.7711",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",