@peopl-health/nexus 5.52.0-dev.7705 → 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.
- package/lib/clinical/config/safetyNetConfig.js +64 -0
- package/lib/clinical/services/assistantService.js +16 -1
- package/lib/clinical/services/clinicalAirtableService.js +3 -1
- package/lib/clinical/services/pausedSafetyNetService.js +275 -0
- package/lib/clinical/services/safetyNetPledgeService.js +120 -0
- package/lib/config/lifecycle.js +8 -0
- package/lib/controllers/escalationReviewController.js +0 -12
- package/lib/core/NexusMessaging.js +2 -0
- package/lib/core/interactiveRouteService.js +59 -0
- package/lib/helpers/patientTaskRulesHelper.js +2 -0
- package/lib/index.d.ts +7 -1
- package/lib/index.js +17 -0
- package/lib/models/escalationReviewModel.js +0 -4
- package/lib/models/safetyNetPledgeModel.js +20 -0
- package/lib/routes/index.js +0 -2
- package/lib/services/escalationReviewService.js +4 -36
- package/lib/services/patientTaskService.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
|
|
10
|
+
|
|
11
|
+
const SAFETY_NET_SOURCE = 'paused_safety_net';
|
|
12
|
+
|
|
13
|
+
const humanReplyFilter = (code, since) => ({
|
|
14
|
+
numero: code,
|
|
15
|
+
from_me: true,
|
|
16
|
+
content_sid: null,
|
|
17
|
+
origin: { $ne: 'assistant' },
|
|
18
|
+
triggeredBy: { $ne: SAFETY_NET_SOURCE },
|
|
19
|
+
createdAt: { $gt: since }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const CHOICE_LABEL_KEYS = {
|
|
23
|
+
urgent: 'PAUSED_SAFETY_NET_URGENT_LABEL',
|
|
24
|
+
callback: 'PAUSED_SAFETY_NET_CALLBACK_LABEL',
|
|
25
|
+
later: 'PAUSED_SAFETY_NET_LATER_LABEL'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let injected = {};
|
|
29
|
+
|
|
30
|
+
const configureSafetyNet = (config = {}) => {
|
|
31
|
+
injected = config && typeof config === 'object' ? config : {};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const _resetSafetyNetConfig = () => {
|
|
35
|
+
injected = {};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const text = (key, envKey) => {
|
|
39
|
+
const raw = injected[key] ?? runtimeConfig.get(envKey);
|
|
40
|
+
const value = String(raw ?? '').trim();
|
|
41
|
+
return value || null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const duration = (key, envKey, fallback) => {
|
|
45
|
+
const raw = Number(injected[key] ?? runtimeConfig.get(envKey));
|
|
46
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
CONTACT_WINDOW,
|
|
51
|
+
SAFETY_NET_SOURCE,
|
|
52
|
+
humanReplyFilter,
|
|
53
|
+
CHOICE_LABEL_KEYS,
|
|
54
|
+
configureSafetyNet,
|
|
55
|
+
_resetSafetyNetConfig,
|
|
56
|
+
getSafetyNetContentSid: () => text('contentSid', 'PAUSED_SAFETY_NET_CONTENT_SID'),
|
|
57
|
+
getSafetyNetPhone: () => text('phone', 'PAUSED_SAFETY_NET_PHONE'),
|
|
58
|
+
getSafetyNetLabel: (choice) => text(`${choice}Label`, CHOICE_LABEL_KEYS[choice]),
|
|
59
|
+
getOfferWindowMs: () => duration('offerWindowMs', 'PAUSED_SAFETY_NET_WINDOW_MS', DEFAULT_OFFER_WINDOW_MS),
|
|
60
|
+
getHumanQuietMs: () => duration('humanQuietMs', 'SAFETY_NET_HUMAN_QUIET_MS', DEFAULT_HUMAN_QUIET_MS),
|
|
61
|
+
getTapWindowMs: () => duration('tapWindowMs', 'SAFETY_NET_TAP_WINDOW_MS', DEFAULT_TAP_WINDOW_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)
|
|
64
|
+
};
|
|
@@ -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)
|
|
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:
|
|
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,275 @@
|
|
|
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, releaseSendClaim } = require('../../helpers/sendClaimHelper');
|
|
15
|
+
|
|
16
|
+
const { normalizePayload: normalize, 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 offerClaims = () => {
|
|
48
|
+
if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
|
|
49
|
+
return offerClaimStore;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const tapClaims = () => {
|
|
53
|
+
if (!tapClaimStore) tapClaimStore = new WriteClaimStore({ namespace: 'safety-net-choice', ttlMs: getTapWindowMs() });
|
|
54
|
+
return tapClaimStore;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const _resetSafetyNetState = () => {
|
|
58
|
+
offerClaimStore = null;
|
|
59
|
+
tapClaimStore = null;
|
|
60
|
+
phoneCheckedFor = null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function matchSafetyNetChoice(payload, title) {
|
|
64
|
+
const candidate = normalize(payload);
|
|
65
|
+
if (candidate.startsWith(PAYLOAD_PREFIX)) {
|
|
66
|
+
const suffix = candidate.slice(PAYLOAD_PREFIX.length);
|
|
67
|
+
return CHOICES.find((choice) => choice === suffix) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const label = normalize(title) || candidate;
|
|
71
|
+
if (!label) return null;
|
|
72
|
+
return CHOICES.find((choice) => {
|
|
73
|
+
const configured = normalize(getSafetyNetLabel(choice));
|
|
74
|
+
return configured && configured === label;
|
|
75
|
+
}) || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
79
|
+
const phone = getSafetyNetPhone();
|
|
80
|
+
if (!phone || phoneCheckedFor === contentSid) return;
|
|
81
|
+
phoneCheckedFor = contentSid;
|
|
82
|
+
try {
|
|
83
|
+
const body = await getMessaging().requireProvider().renderTemplate(contentSid);
|
|
84
|
+
const digits = (value) => String(value ?? '').replace(/\D/g, '');
|
|
85
|
+
if (body && !digits(body).includes(digits(phone))) {
|
|
86
|
+
logger.error('[PausedSafetyNet] The configured phone number is absent from the template body', { contentSid });
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
phoneCheckedFor = null;
|
|
90
|
+
logger.warn('[PausedSafetyNet] Could not read the template to check the phone number', { contentSid, error: error.message });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function offerSafetyNet({ code }) {
|
|
95
|
+
try {
|
|
96
|
+
const contentSid = getSafetyNetContentSid();
|
|
97
|
+
if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
98
|
+
if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
|
|
99
|
+
if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
|
|
100
|
+
|
|
101
|
+
await warnIfPhoneDiffersFromTemplate(contentSid);
|
|
102
|
+
|
|
103
|
+
const result = await getMessaging().sendMessage({ code, contentSid, triggeredBy: SOURCE });
|
|
104
|
+
const sent = result?.success === true || result?.completed === true;
|
|
105
|
+
if (!sent) return { sent: false, reason: result?.status || 'send_failed' };
|
|
106
|
+
|
|
107
|
+
await offerClaims().set(code, true);
|
|
108
|
+
return { sent: true, reason: null };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
logger.error('[PausedSafetyNet] Could not offer the safety net', { code, error: error.message });
|
|
111
|
+
return { sent: false, reason: 'error' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fileUrgentRequest(code, choice) {
|
|
116
|
+
const copy = REQUEST_COPY[choice];
|
|
117
|
+
return recordUnresolvedRequest({
|
|
118
|
+
code,
|
|
119
|
+
trace: null,
|
|
120
|
+
question: copy.question,
|
|
121
|
+
details: copy.details(code),
|
|
122
|
+
category: REQUEST_CATEGORY,
|
|
123
|
+
urgency: 'urgent'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function markCanWait(code) {
|
|
128
|
+
const { requests = [] } = await getPatientTasks(code);
|
|
129
|
+
const target = requests.find((request) => request.status === ATTENDED_STATUS
|
|
130
|
+
&& request.category === REQUEST_CATEGORY
|
|
131
|
+
&& String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
|
|
132
|
+
if (!target) return false;
|
|
133
|
+
|
|
134
|
+
await updatePatientTask({
|
|
135
|
+
code,
|
|
136
|
+
kind: 'requests',
|
|
137
|
+
recordId: target.recordId,
|
|
138
|
+
changes: {
|
|
139
|
+
urgency: 'routine',
|
|
140
|
+
note: [target.note, CAN_WAIT_NOTE].filter(Boolean).join('\n')
|
|
141
|
+
},
|
|
142
|
+
updatedBy: SOURCE,
|
|
143
|
+
source: SOURCE
|
|
144
|
+
});
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function applyChoice(code, choice) {
|
|
149
|
+
if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
|
|
150
|
+
|
|
151
|
+
const queued = await fileUrgentRequest(code, choice);
|
|
152
|
+
if (choice !== 'callback') return { queued, pledge: null };
|
|
153
|
+
if (!queued) return { queued, pledge: null };
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
return { queued, pledge: await openPledge({ code }) };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
logger.error('[PausedSafetyNet] The request was filed but no pledge was opened', { code, error: error.message });
|
|
159
|
+
return { queued, pledge: null };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const withPhone = (text, closing) => {
|
|
164
|
+
const phone = getSafetyNetPhone();
|
|
165
|
+
return phone ? `${text} ${closing.replace('{phone}', phone)}` : text;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function ackFor(choice, outcome) {
|
|
169
|
+
if (outcome.stale) {
|
|
170
|
+
return withPhone('Recibimos tu respuesta y el equipo va a revisar tu conversación.', 'Si necesitas ayuda ahora, llámanos al {phone}.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!outcome.queued) {
|
|
174
|
+
return withPhone('No pudimos registrar tu solicitud.', 'Por favor llámanos al {phone} para que te atendamos.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (choice === 'urgent') {
|
|
178
|
+
return withPhone('Entendido, ya avisamos al equipo.', 'Si es una urgencia médica, llámanos ahora al {phone}.');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (choice === 'later') {
|
|
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
|
+
}
|
|
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
|
+
return true;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleSafetyNetTap({ code, payload, title }) {
|
|
216
|
+
const choice = matchSafetyNetChoice(payload, title);
|
|
217
|
+
if (!choice || !code) return { handled: false };
|
|
218
|
+
|
|
219
|
+
const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
|
|
220
|
+
if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
|
|
221
|
+
|
|
222
|
+
let acknowledged = false;
|
|
223
|
+
try {
|
|
224
|
+
if (!await offerClaims().get(code)) {
|
|
225
|
+
acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
226
|
+
return { handled: true, choice, queued: false, stale: true };
|
|
227
|
+
}
|
|
228
|
+
await offerClaims().set(code, true);
|
|
229
|
+
|
|
230
|
+
const chosen = await tapClaims().get(code);
|
|
231
|
+
const repeated = Boolean(chosen);
|
|
232
|
+
|
|
233
|
+
let outcome = { queued: true, pledge: null, repeated };
|
|
234
|
+
if (!repeated) {
|
|
235
|
+
try {
|
|
236
|
+
outcome = { ...await applyChoice(code, choice), repeated: false };
|
|
237
|
+
await tapClaims().set(code, choice);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
|
|
240
|
+
outcome = { queued: false, pledge: null, repeated: false };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
acknowledged = await sendAck(code, choice, outcome);
|
|
245
|
+
return { handled: true, choice, ...outcome };
|
|
246
|
+
} finally {
|
|
247
|
+
if (acknowledged) await completeSendClaim(claim.claimId);
|
|
248
|
+
else await releaseSendClaim(claim.claimId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function registerSafetyNetRoutes() {
|
|
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
|
+
}
|
|
258
|
+
|
|
259
|
+
registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
|
|
260
|
+
for (const choice of CHOICES) {
|
|
261
|
+
const label = getSafetyNetLabel(choice);
|
|
262
|
+
if (label) registerInteractiveRoute(label, handleSafetyNetTap);
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
offerSafetyNet,
|
|
269
|
+
handleSafetyNetTap,
|
|
270
|
+
matchSafetyNetChoice,
|
|
271
|
+
registerSafetyNetRoutes,
|
|
272
|
+
_resetSafetyNetState,
|
|
273
|
+
PAYLOAD_PREFIX,
|
|
274
|
+
CHOICES
|
|
275
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const { CONTACT_WINDOW, getPledgeMs, getSweepIntervalMs, 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
|
+
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
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
startSafetyNetSweep,
|
|
114
|
+
stopSafetyNetSweep,
|
|
115
|
+
humanRepliedSince,
|
|
116
|
+
openPledge,
|
|
117
|
+
pledgeDueAt,
|
|
118
|
+
sweepSafetyNetPledges,
|
|
119
|
+
BREACH_QUESTION
|
|
120
|
+
};
|
package/lib/config/lifecycle.js
CHANGED
|
@@ -3,7 +3,11 @@ 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');
|
|
10
|
+
const { stopSafetyNetSweep } = require('../clinical/services/safetyNetPledgeService');
|
|
7
11
|
const { _clearRecentIntakeCache } = require('../clinical/services/clinicalAirtableService');
|
|
8
12
|
const { _clearExtractorConfigCache } = require('../clinical/services/clinicalExtractionService');
|
|
9
13
|
const { _resetRegistry } = require('../clinical/services/assistantResolver');
|
|
@@ -14,6 +18,10 @@ function resetAll() {
|
|
|
14
18
|
_resetRegistry();
|
|
15
19
|
_resetLLMState();
|
|
16
20
|
setPreprocessingHandler(null);
|
|
21
|
+
clearInteractiveRoutes();
|
|
22
|
+
_resetSafetyNetConfig();
|
|
23
|
+
_resetSafetyNetState();
|
|
24
|
+
stopSafetyNetSweep();
|
|
17
25
|
_resetOverrides();
|
|
18
26
|
_resetMetaConfig();
|
|
19
27
|
_clearClinicalContextCache();
|
|
@@ -4,7 +4,6 @@ const { redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
|
4
4
|
const {
|
|
5
5
|
addCallLog,
|
|
6
6
|
addDraft,
|
|
7
|
-
cancelEscalationReview,
|
|
8
7
|
closeEscalationReview,
|
|
9
8
|
getEscalationReviewById,
|
|
10
9
|
handOffToDoctor,
|
|
@@ -136,16 +135,6 @@ const closeEscalationReviewController = async (req, res) => {
|
|
|
136
135
|
}
|
|
137
136
|
};
|
|
138
137
|
|
|
139
|
-
const cancelEscalationReviewController = async (req, res) => {
|
|
140
|
-
try {
|
|
141
|
-
const { triggeredBy, reason = null } = req.body || {};
|
|
142
|
-
const review = await cancelEscalationReview({ id: req.params.id, cancelledBy: triggeredBy, reason, requestId: req.requestId });
|
|
143
|
-
res.status(200).json({ success: true, review });
|
|
144
|
-
} catch (error) {
|
|
145
|
-
respondWithError(res, error, 'cancelling escalation review', { reviewId: req.params?.id });
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
|
|
149
138
|
const syncEscalationReviewCaseController = async (req, res) => {
|
|
150
139
|
try {
|
|
151
140
|
const { triggeredBy } = req.body || {};
|
|
@@ -163,7 +152,6 @@ const syncEscalationReviewCaseController = async (req, res) => {
|
|
|
163
152
|
module.exports = {
|
|
164
153
|
addEscalationReviewCallLogController,
|
|
165
154
|
addEscalationReviewDraftController,
|
|
166
|
-
cancelEscalationReviewController,
|
|
167
155
|
closeEscalationReviewController,
|
|
168
156
|
getEscalationReviewController,
|
|
169
157
|
handOffEscalationReviewController,
|
|
@@ -45,6 +45,7 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
|
|
|
45
45
|
const { createQueueAdapter } = require('../queue');
|
|
46
46
|
const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
|
|
47
47
|
const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
|
|
48
|
+
const { invokeInteractiveRoute } = require('./interactiveRouteService');
|
|
48
49
|
|
|
49
50
|
const { PhiProcessor } = require('./PhiProcessor');
|
|
50
51
|
|
|
@@ -580,6 +581,7 @@ class NexusMessaging {
|
|
|
580
581
|
}
|
|
581
582
|
|
|
582
583
|
async handleInteractive(messageData) {
|
|
584
|
+
await invokeInteractiveRoute(messageData);
|
|
583
585
|
if (this.handlers.onInteractive) return await this.handlers.onInteractive(messageData, this);
|
|
584
586
|
}
|
|
585
587
|
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
normalizePayload,
|
|
54
|
+
registerInteractiveRoute,
|
|
55
|
+
clearInteractiveRoutes,
|
|
56
|
+
hasInteractiveRoutes,
|
|
57
|
+
resolveInteractiveRoute,
|
|
58
|
+
invokeInteractiveRoute
|
|
59
|
+
};
|
|
@@ -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,10 @@ 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');
|
|
32
|
+
const { startSafetyNetSweep, stopSafetyNetSweep } = require('./clinical/services/safetyNetPledgeService');
|
|
29
33
|
const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
|
|
30
34
|
const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
|
|
31
35
|
const { createWorkflowRunner } = require('./core/workflowRunner');
|
|
@@ -184,6 +188,16 @@ class Nexus {
|
|
|
184
188
|
logger.warn('Warning: failed to register clinical tools:', e?.message || e);
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
try {
|
|
192
|
+
configureSafetyNet(options.safetyNet || {});
|
|
193
|
+
if (registerSafetyNetRoutes()) {
|
|
194
|
+
startSafetyNetSweep();
|
|
195
|
+
logger.info('[Nexus] paused-thread safety net armed');
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
logger.warn('Warning: failed to arm the paused-thread safety net:', e?.message || e);
|
|
199
|
+
}
|
|
200
|
+
|
|
187
201
|
await this.messaging.initializeLlmMonitor();
|
|
188
202
|
|
|
189
203
|
this.isInitialized = true;
|
|
@@ -238,6 +252,7 @@ class Nexus {
|
|
|
238
252
|
}
|
|
239
253
|
|
|
240
254
|
async shutdown({ drainMs, abortGraceMs, teardownMs, closeStorage = true } = {}) {
|
|
255
|
+
stopSafetyNetSweep();
|
|
241
256
|
if (this._shutdownPromise) return this._shutdownPromise;
|
|
242
257
|
this._shutdownPromise = (async () => {
|
|
243
258
|
const result = await this.messaging.shutdown({ drainMs, abortGraceMs, teardownMs });
|
|
@@ -280,6 +295,8 @@ module.exports = {
|
|
|
280
295
|
setPreprocessingHandler,
|
|
281
296
|
hasPreprocessingHandler,
|
|
282
297
|
invokePreprocessingHandler,
|
|
298
|
+
registerInteractiveRoute,
|
|
299
|
+
clearInteractiveRoutes,
|
|
283
300
|
routes,
|
|
284
301
|
syncClinicalContext,
|
|
285
302
|
runProactiveOutreachSweep,
|
|
@@ -7,7 +7,6 @@ const { logger } = require('../utils/logger');
|
|
|
7
7
|
const CALL_OUTCOMES = ['contacted', 'no_answer', 'voicemail'];
|
|
8
8
|
const HANDOFF_MODES = ['sent_whatsapp', 'copied'];
|
|
9
9
|
const INTERRUPTED_STATUSES = ['handing_off', 'sending'];
|
|
10
|
-
const CANCELLABLE_STATUSES = ['open', 'documenting', 'draft_ready'];
|
|
11
10
|
const DOCTOR_DECISIONS = ['approved', 'edited_approved', 'needs_intervention', 'returned'];
|
|
12
11
|
const DECISION_SOURCES = ['platform', 'registered_by_navigator'];
|
|
13
12
|
|
|
@@ -84,7 +83,6 @@ const escalationReviewSchema = new mongoose.Schema({
|
|
|
84
83
|
'indications_queued',
|
|
85
84
|
'indications_sent',
|
|
86
85
|
'closed',
|
|
87
|
-
'cancelled',
|
|
88
86
|
],
|
|
89
87
|
default: 'open',
|
|
90
88
|
},
|
|
@@ -102,7 +100,6 @@ const escalationReviewSchema = new mongoose.Schema({
|
|
|
102
100
|
|
|
103
101
|
escalationReviewSchema.index({ patientCode: 1, createdAt: -1 });
|
|
104
102
|
escalationReviewSchema.index({ activeCaseKey: 1 }, { unique: true, sparse: true });
|
|
105
|
-
escalationReviewSchema.index({ patientCode: 1 }, { unique: true, partialFilterExpression: { closedAt: null } });
|
|
106
103
|
|
|
107
104
|
const getEscalationReview = () => {
|
|
108
105
|
const dbName = getModelDatabase('EscalationReview');
|
|
@@ -113,7 +110,6 @@ const getEscalationReview = () => {
|
|
|
113
110
|
|
|
114
111
|
module.exports = {
|
|
115
112
|
CALL_OUTCOMES,
|
|
116
|
-
CANCELLABLE_STATUSES,
|
|
117
113
|
DECISION_SOURCES,
|
|
118
114
|
DOCTOR_DECISIONS,
|
|
119
115
|
HANDOFF_MODES,
|
|
@@ -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 };
|
package/lib/routes/index.js
CHANGED
|
@@ -126,7 +126,6 @@ const escalationReviewRouteDefinitions = {
|
|
|
126
126
|
'POST /:id/decisions': 'recordEscalationReviewDecisionController',
|
|
127
127
|
'POST /:id/patient-deliveries': 'sendEscalationReviewIndicationsController',
|
|
128
128
|
'POST /:id/close': 'closeEscalationReviewController',
|
|
129
|
-
'POST /:id/cancel': 'cancelEscalationReviewController',
|
|
130
129
|
'POST /:id/case-sync': 'syncEscalationReviewCaseController'
|
|
131
130
|
};
|
|
132
131
|
|
|
@@ -286,7 +285,6 @@ const builtInControllers = {
|
|
|
286
285
|
recordEscalationReviewDecisionController: escalationReviewController.recordEscalationReviewDecisionController,
|
|
287
286
|
sendEscalationReviewIndicationsController: escalationReviewController.sendEscalationReviewIndicationsController,
|
|
288
287
|
closeEscalationReviewController: escalationReviewController.closeEscalationReviewController,
|
|
289
|
-
cancelEscalationReviewController: escalationReviewController.cancelEscalationReviewController,
|
|
290
288
|
syncEscalationReviewCaseController: escalationReviewController.syncEscalationReviewCaseController
|
|
291
289
|
};
|
|
292
290
|
|
|
@@ -6,7 +6,6 @@ const { logger } = require('../utils/logger');
|
|
|
6
6
|
const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
7
7
|
const {
|
|
8
8
|
CALL_OUTCOMES,
|
|
9
|
-
CANCELLABLE_STATUSES,
|
|
10
9
|
DECISION_SOURCES,
|
|
11
10
|
DOCTOR_DECISIONS,
|
|
12
11
|
HANDOFF_MODES,
|
|
@@ -249,24 +248,6 @@ const deliveryTransport = (message, result) => {
|
|
|
249
248
|
return result.deferred ? 'template_recovery' : 'text';
|
|
250
249
|
};
|
|
251
250
|
|
|
252
|
-
let indexesReady = new WeakMap();
|
|
253
|
-
|
|
254
|
-
function ensureActiveReviewIndex(EscalationReview) {
|
|
255
|
-
const pending = indexesReady.get(EscalationReview);
|
|
256
|
-
if (pending) return pending;
|
|
257
|
-
|
|
258
|
-
const building = EscalationReview.ensureIndexes().catch((error) => {
|
|
259
|
-
indexesReady.delete(EscalationReview);
|
|
260
|
-
if (error?.code !== DUPLICATE_KEY_ERROR) throw error;
|
|
261
|
-
logger.error('[EscalationReview] A uniqueness index could not be built', { error: redactedError(error) });
|
|
262
|
-
throw httpError(409, 'Escalation reviews cannot be opened: a uniqueness index could not be built. The server log names the index and the conflict; resolve it and retry.');
|
|
263
|
-
});
|
|
264
|
-
indexesReady.set(EscalationReview, building);
|
|
265
|
-
return building;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const _resetEscalationReviewIndexes = () => { indexesReady = new WeakMap(); };
|
|
269
|
-
|
|
270
251
|
async function openEscalationReview({ code, patientCaseRecordId = null, openedBy, requestId = null } = {}) {
|
|
271
252
|
const patientCode = patientCodeOf(code);
|
|
272
253
|
const operator = requiredText(openedBy, 'openedBy');
|
|
@@ -283,14 +264,14 @@ async function openEscalationReview({ code, patientCaseRecordId = null, openedBy
|
|
|
283
264
|
}
|
|
284
265
|
|
|
285
266
|
const EscalationReview = getEscalationReview();
|
|
286
|
-
await
|
|
267
|
+
await EscalationReview.init();
|
|
287
268
|
let review;
|
|
288
269
|
try {
|
|
289
270
|
review = (await EscalationReview.create(fields)).toObject();
|
|
290
271
|
} catch (error) {
|
|
291
|
-
if (error?.code !== DUPLICATE_KEY_ERROR) throw error;
|
|
292
|
-
const existing = await EscalationReview.findOne({
|
|
293
|
-
if (!existing) throw httpError(409, '
|
|
272
|
+
if (error?.code !== DUPLICATE_KEY_ERROR || !fields.activeCaseKey) throw error;
|
|
273
|
+
const existing = await EscalationReview.findOne({ activeCaseKey: fields.activeCaseKey }).lean();
|
|
274
|
+
if (!existing) throw httpError(409, 'The patient case review changed while opening; try again');
|
|
294
275
|
return { review: existing, created: false };
|
|
295
276
|
}
|
|
296
277
|
await audit(review, operator, requestId, { action: 'opened', patientCaseRecordId });
|
|
@@ -543,17 +524,6 @@ async function closeEscalationReview({ id, closedBy, deliveryConfirmed = false,
|
|
|
543
524
|
return syncCase(review, operator, requestId);
|
|
544
525
|
}
|
|
545
526
|
|
|
546
|
-
async function cancelEscalationReview({ id, cancelledBy, reason = null, requestId = null } = {}) {
|
|
547
|
-
const reviewId = reviewIdOf(id);
|
|
548
|
-
const operator = requiredText(cancelledBy, 'cancelledBy');
|
|
549
|
-
const review = await updateReview(reviewId, CANCELLABLE_STATUSES, {
|
|
550
|
-
$set: { status: 'cancelled', closedBy: operator, closedAt: new Date() },
|
|
551
|
-
$unset: { activeCaseKey: '' },
|
|
552
|
-
});
|
|
553
|
-
await audit(review, operator, requestId, { action: 'cancelled', reason: optionalText(reason) });
|
|
554
|
-
return review;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
527
|
async function syncEscalationReviewCase({ id, syncedBy, requestId = null } = {}) {
|
|
558
528
|
const reviewId = reviewIdOf(id);
|
|
559
529
|
const operator = requiredText(syncedBy, 'syncedBy');
|
|
@@ -561,10 +531,8 @@ async function syncEscalationReviewCase({ id, syncedBy, requestId = null } = {})
|
|
|
561
531
|
}
|
|
562
532
|
|
|
563
533
|
module.exports = {
|
|
564
|
-
_resetEscalationReviewIndexes,
|
|
565
534
|
addCallLog,
|
|
566
535
|
addDraft,
|
|
567
|
-
cancelEscalationReview,
|
|
568
536
|
closeEscalationReview,
|
|
569
537
|
getEscalationReviewById,
|
|
570
538
|
handOffToDoctor,
|
|
@@ -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
|
|