@peopl-health/nexus 5.52.0-dev.7718 → 5.52.0-dev.7721
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/flags/recommendationRecoveryFlags.js +9 -0
- package/lib/clinical/helpers/triageRetriggerHelper.js +85 -0
- package/lib/clinical/services/assistantService.js +1 -16
- package/lib/clinical/services/clinicalAirtableService.js +1 -3
- package/lib/clinical/services/recommendationRecoveryService.js +175 -0
- package/lib/clinical/services/writeClaimStore.js +28 -0
- package/lib/config/lifecycle.js +0 -8
- package/lib/controllers/triageRecommendationController.js +21 -86
- package/lib/core/NexusMessaging.js +2 -2
- package/lib/helpers/patientTaskRulesHelper.js +0 -2
- package/lib/index.d.ts +1 -7
- package/lib/index.js +0 -17
- package/lib/services/patientTaskService.js +1 -3
- package/lib/services/queueReconciliationService.js +31 -9
- package/package.json +1 -1
- package/lib/clinical/config/safetyNetConfig.js +0 -64
- package/lib/clinical/services/pausedSafetyNetService.js +0 -298
- package/lib/clinical/services/safetyNetPledgeService.js +0 -120
- package/lib/core/interactiveRouteService.js +0 -59
- package/lib/models/safetyNetPledgeModel.js +0 -20
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const runtimeConfig = require('../../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
function recommendationRetryLive() {
|
|
4
|
+
return String(runtimeConfig.get('RECOMMENDATION_RETRY_LIVE', 'true')).trim().toLowerCase() === 'true';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
recommendationRetryLive,
|
|
9
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { Symptoms_ID } = require('../../config/airtableConfig');
|
|
2
|
+
const { logger } = require('../../utils/logger');
|
|
3
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
|
+
const { buildPayloadFromTriageRow } = require('../../fhir/helpers/triagePayloadHelper');
|
|
5
|
+
|
|
6
|
+
const RECORD_ID_RX = /^rec[A-Za-z0-9]+$/;
|
|
7
|
+
const ITEM_TABLE = 'pro_ctcae_questions';
|
|
8
|
+
const ITEM_FIELD = 'item_proctcae';
|
|
9
|
+
|
|
10
|
+
const linkIdsOf = (value) => (Array.isArray(value) ? value : []).filter((id) => RECORD_ID_RX.test(String(id || '')));
|
|
11
|
+
|
|
12
|
+
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
13
|
+
|
|
14
|
+
async function resolveLinkedItems(links, triageRecordId) {
|
|
15
|
+
const resolved = Object.fromEntries(Object.keys(links).map((field) => [field, []]));
|
|
16
|
+
const ids = [...new Set(Object.values(links).flatMap(linkIdsOf))];
|
|
17
|
+
const byId = new Map();
|
|
18
|
+
if (ids.length) {
|
|
19
|
+
const filter = `OR(${ids.map((id) => `RECORD_ID()='${id}'`).join(',')})`;
|
|
20
|
+
let rows;
|
|
21
|
+
try {
|
|
22
|
+
rows = await getRecordByFilter(Symptoms_ID, ITEM_TABLE, filter, null, [ITEM_FIELD], true);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
for (const row of (Array.isArray(rows) ? rows : [])) byId.set(row._recordId, row[ITEM_FIELD]);
|
|
27
|
+
}
|
|
28
|
+
for (const [field, value] of Object.entries(links)) {
|
|
29
|
+
const linked = (Array.isArray(value) ? value : []).length;
|
|
30
|
+
const wanted = linkIdsOf(value);
|
|
31
|
+
const items = wanted.map((id) => byId.get(id)).filter((item) => item != null && String(item).trim() !== '');
|
|
32
|
+
if (items.length !== linked) {
|
|
33
|
+
logger.warn('[TriageRecommendationController] unresolved triage item links', {
|
|
34
|
+
triageRecordId, field, linked, matchedIdShape: wanted.length, resolved: items.length,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
resolved[field] = items;
|
|
38
|
+
}
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function buildRetriggerPayload({ triageRecordId, code }) {
|
|
43
|
+
if (typeof triageRecordId !== 'string' || !RECORD_ID_RX.test(triageRecordId.trim())) {
|
|
44
|
+
return fail('invalid_triage_id', 'triage_record_id must be an Airtable record id');
|
|
45
|
+
}
|
|
46
|
+
if (!code || typeof code !== 'string') return fail('missing_code', 'code is required');
|
|
47
|
+
|
|
48
|
+
const safeId = triageRecordId.trim();
|
|
49
|
+
const rows = await getRecordByFilter(Symptoms_ID, 'triaje_symptoms', `RECORD_ID()='${safeId}'`).catch(() => undefined);
|
|
50
|
+
if (!Array.isArray(rows)) return fail('triage_read_failed', 'triage record read failed');
|
|
51
|
+
const row = rows[0];
|
|
52
|
+
if (!row) return fail('triage_not_found', 'triage record not found');
|
|
53
|
+
|
|
54
|
+
const patientLink = Array.isArray(row.patient_id) ? row.patient_id[0] : row.patient_id;
|
|
55
|
+
if (!patientLink) return fail('no_patient_link', 'triage row has no patient link');
|
|
56
|
+
|
|
57
|
+
const patients = await getRecordByFilter(Symptoms_ID, 'estado_general', `RECORD_ID()='${patientLink}'`).catch(() => undefined);
|
|
58
|
+
if (!Array.isArray(patients)) return fail('patient_read_failed', 'patient record read failed');
|
|
59
|
+
if (!patients[0]) return fail('patient_not_found', 'patient record not found');
|
|
60
|
+
if (patients[0].whatsapp_id !== code) return fail('code_mismatch', 'code does not match the triage row patient');
|
|
61
|
+
|
|
62
|
+
const linkedItems = await resolveLinkedItems({
|
|
63
|
+
additional_symptoms_select: row.additional_symptoms_select,
|
|
64
|
+
pain_multiselect: row.pain_multiselect,
|
|
65
|
+
}, safeId);
|
|
66
|
+
if (!linkedItems) return fail('item_read_failed', 'triage item read failed');
|
|
67
|
+
|
|
68
|
+
const cadence = row['type_quest (from questionnaire_id)'];
|
|
69
|
+
const {
|
|
70
|
+
additional_symptoms_select: additionalSymptoms,
|
|
71
|
+
pain_multiselect: painOptions,
|
|
72
|
+
} = linkedItems;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
payload: buildPayloadFromTriageRow(
|
|
77
|
+
{ ...row, type_quest: Array.isArray(cadence) ? cadence[0] : cadence ?? row.type_quest },
|
|
78
|
+
{ patientCode: code, triageRecordId: safeId, additionalSymptoms, painOptions },
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
buildRetriggerPayload,
|
|
85
|
+
};
|
|
@@ -16,19 +16,9 @@ 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');
|
|
20
19
|
|
|
21
20
|
const MEDIA_BACKLOG_LIMIT = 10;
|
|
22
21
|
|
|
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
|
-
|
|
32
22
|
const fetchMediaBacklog = async (code, trigger) => {
|
|
33
23
|
const [lastOutbound] = await getMessages(
|
|
34
24
|
{ numero: code, from_me: true, createdAt: { $lt: trigger.createdAt } },
|
|
@@ -122,12 +112,7 @@ const preProcessMessagesCore = async (code, message_ = null, thread) => {
|
|
|
122
112
|
return { shouldProcess: false, messages: null, timings, pendingMediaUpdates: [] };
|
|
123
113
|
}
|
|
124
114
|
|
|
125
|
-
if (thread.stopped) {
|
|
126
|
-
await Promise.allSettled([
|
|
127
|
-
recordPausedThreadMessage({ code, text: await pausedAlertText(code, lastMessage[0]) }),
|
|
128
|
-
offerSafetyNet({ code })
|
|
129
|
-
]);
|
|
130
|
-
}
|
|
115
|
+
if (thread.stopped) await recordPausedThreadMessage({ code, text: lastMessage[0].plainBody || lastMessage[0].body });
|
|
131
116
|
|
|
132
117
|
const backlog = await fetchMediaBacklog(code, lastMessage[0]);
|
|
133
118
|
const replies = [...backlog, ...lastMessage];
|
|
@@ -14,7 +14,6 @@ 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:';
|
|
18
17
|
const PAUSED_ALERT_WINDOW_MS = 4 * 60 * 60 * 1000;
|
|
19
18
|
const recentPausedAlertCache = new WriteClaimStore({ namespace: 'paused-alert', ttlMs: PAUSED_ALERT_WINDOW_MS, maxSize: 1000 });
|
|
20
19
|
const UNRESOLVED_WINDOW_MS = 30 * 60 * 1000;
|
|
@@ -120,7 +119,7 @@ async function recordPausedThreadMessage({ code, text }) {
|
|
|
120
119
|
code,
|
|
121
120
|
trace: null,
|
|
122
121
|
question: text || 'El paciente envió un archivo sin texto.',
|
|
123
|
-
details:
|
|
122
|
+
details: `Bot en pausa: el paciente ${code} escribió y nadie ha respondido. Revisar la conversación.`,
|
|
124
123
|
category: 'contact_team_member',
|
|
125
124
|
urgency: 'soon',
|
|
126
125
|
});
|
|
@@ -502,7 +501,6 @@ async function recordClusterRecommendationsMirror({ code, triageRecordId, step1
|
|
|
502
501
|
}
|
|
503
502
|
|
|
504
503
|
module.exports = {
|
|
505
|
-
PAUSED_THREAD_DETAILS_PREFIX,
|
|
506
504
|
recordUnresolvedRequest,
|
|
507
505
|
recordPausedThreadMessage,
|
|
508
506
|
recordEmergency,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const { buildRetriggerPayload } = require('../helpers/triageRetriggerHelper');
|
|
2
|
+
const { Symptoms_ID } = require('../../config/airtableConfig');
|
|
3
|
+
const { logger } = require('../../utils/logger');
|
|
4
|
+
const { getRecordByFilter } = require('../../services/airtableService');
|
|
5
|
+
const { recommendationRetryLive } = require('../flags/recommendationRecoveryFlags');
|
|
6
|
+
const { Thread } = require('../../models/threadModel');
|
|
7
|
+
const { recordUnresolvedRequest } = require('./clinicalAirtableService');
|
|
8
|
+
const { handleTriageCompleted } = require('./triageRecommendationService');
|
|
9
|
+
const { WriteClaimStore } = require('./writeClaimStore');
|
|
10
|
+
|
|
11
|
+
const TRIAGE_TABLE = 'triaje_symptoms';
|
|
12
|
+
const SCAN_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const MIN_AGE_MS = 15 * 60 * 1000;
|
|
14
|
+
const RETRY_MAX_AGE_MS = 60 * 60 * 1000;
|
|
15
|
+
const MAX_CANDIDATES = 20;
|
|
16
|
+
const MAX_RETRIES_PER_PASS = 3;
|
|
17
|
+
const CLAIM_TTL_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const retryClaims = new WriteClaimStore({ namespace: 'recom-orphan-retry', ttlMs: CLAIM_TTL_MS, maxSize: 500 });
|
|
20
|
+
const ticketClaims = new WriteClaimStore({ namespace: 'recom-orphan-ticket', ttlMs: SCAN_WINDOW_MS, maxSize: 500 });
|
|
21
|
+
|
|
22
|
+
const FIELDS = ['Created', 'triaje_symptoms_id', 'whatsapp_id', 'symptom_name_positive'];
|
|
23
|
+
|
|
24
|
+
function orphanFormula(now) {
|
|
25
|
+
const newest = new Date(now.getTime() - MIN_AGE_MS).toISOString();
|
|
26
|
+
const oldest = new Date(now.getTime() - SCAN_WINDOW_MS).toISOString();
|
|
27
|
+
return [
|
|
28
|
+
'AND(',
|
|
29
|
+
'ARRAYJOIN({source})=\'auna\',',
|
|
30
|
+
'COUNTA({symptom_name_positive})>0,',
|
|
31
|
+
'COUNTA({cluster_recommendations})=0,',
|
|
32
|
+
`IS_AFTER({Created}, '${oldest}'),`,
|
|
33
|
+
`IS_BEFORE({Created}, '${newest}')`,
|
|
34
|
+
')',
|
|
35
|
+
].join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function firstValue(value) {
|
|
39
|
+
return Array.isArray(value) ? value[0] : value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function describe(row) {
|
|
43
|
+
return {
|
|
44
|
+
triageRecordId: row._recordId,
|
|
45
|
+
triageId: firstValue(row.triaje_symptoms_id) ?? null,
|
|
46
|
+
code: firstValue(row.whatsapp_id) ?? null,
|
|
47
|
+
createdAt: row.Created ?? null,
|
|
48
|
+
positiveSymptoms: (Array.isArray(row.symptom_name_positive) ? row.symptom_name_positive : []).length,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function botIsPaused(code) {
|
|
53
|
+
if (!code) return false;
|
|
54
|
+
try {
|
|
55
|
+
const paused = await Thread.findOne({ code, stopped: true }, { _id: 1 }).lean();
|
|
56
|
+
return Boolean(paused);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.warn('[recommendationRecovery] thread state unreadable; treating the bot as paused', { code, error: error.message });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fileOpsTicket(orphan) {
|
|
64
|
+
if (!orphan.code) return false;
|
|
65
|
+
return recordUnresolvedRequest({
|
|
66
|
+
code: orphan.code,
|
|
67
|
+
trace: null,
|
|
68
|
+
question: 'El triaje de la paciente no generó recomendaciones.',
|
|
69
|
+
details: `Triaje ${orphan.triageId ?? orphan.triageRecordId} (${orphan.createdAt}) con ${orphan.positiveSymptoms} síntomas positivos no tiene recomendaciones. Revisar si procede reenviarlas.`,
|
|
70
|
+
category: 'contact_team_member',
|
|
71
|
+
urgency: 'soon',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function retriggerTriage(orphan) {
|
|
76
|
+
const built = await buildRetriggerPayload({ triageRecordId: orphan.triageRecordId, code: orphan.code });
|
|
77
|
+
if (!built.ok) throw new Error(built.reason);
|
|
78
|
+
handleTriageCompleted(built.payload, { trigger: 'retry' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function sweepUnrecoveredTriages({
|
|
82
|
+
now = new Date(),
|
|
83
|
+
retrigger = retriggerTriage,
|
|
84
|
+
alert = fileOpsTicket,
|
|
85
|
+
retryLive = recommendationRetryLive,
|
|
86
|
+
claims = retryClaims,
|
|
87
|
+
ticketGate = ticketClaims,
|
|
88
|
+
isPaused = botIsPaused,
|
|
89
|
+
} = {}) {
|
|
90
|
+
let rows;
|
|
91
|
+
try {
|
|
92
|
+
rows = await getRecordByFilter(Symptoms_ID, TRIAGE_TABLE, orphanFormula(now), null, FIELDS, true);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error('[recommendationRecovery] orphan scan failed', { error: error.message });
|
|
95
|
+
return { scanned: 0, retried: 0, alerted: 0, skipped: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const orphans = (Array.isArray(rows) ? rows : []).map(describe).filter((orphan) => orphan.triageRecordId);
|
|
99
|
+
if (!orphans.length) return { scanned: 0, retried: 0, alerted: 0, skipped: 0 };
|
|
100
|
+
|
|
101
|
+
if (orphans.length > MAX_CANDIDATES) {
|
|
102
|
+
logger.error('[recommendationRecovery] too many triages without recommendations to act on', {
|
|
103
|
+
found: orphans.length, ceiling: MAX_CANDIDATES,
|
|
104
|
+
});
|
|
105
|
+
return { scanned: orphans.length, retried: 0, alerted: 0, skipped: orphans.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const canRetry = retryLive() && typeof retrigger === 'function';
|
|
109
|
+
let retried = 0;
|
|
110
|
+
let alerted = 0;
|
|
111
|
+
let skipped = 0;
|
|
112
|
+
|
|
113
|
+
for (const orphan of orphans) {
|
|
114
|
+
const ageMs = now.getTime() - new Date(orphan.createdAt).getTime();
|
|
115
|
+
const fresh = Number.isFinite(ageMs) && ageMs <= RETRY_MAX_AGE_MS;
|
|
116
|
+
|
|
117
|
+
const paused = (fresh && canRetry) ? await isPaused(orphan.code) : false;
|
|
118
|
+
|
|
119
|
+
if (fresh && canRetry && !paused && retried < MAX_RETRIES_PER_PASS) {
|
|
120
|
+
const claim = await claims.acquire(orphan.triageRecordId, 'retried');
|
|
121
|
+
if (claim === 'taken') { skipped += 1; continue; }
|
|
122
|
+
if (claim === 'unavailable') {
|
|
123
|
+
skipped += 1;
|
|
124
|
+
logger.error('[recommendationRecovery] triage produced no recommendations and could not be claimed', { ...orphan, ageMs });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
logger.error('[recommendationRecovery] triage produced no recommendations, retrying', { ...orphan, ageMs });
|
|
128
|
+
try {
|
|
129
|
+
await retrigger(orphan);
|
|
130
|
+
retried += 1;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await claims.release(orphan.triageRecordId);
|
|
133
|
+
logger.error('[recommendationRecovery] retry could not be dispatched', { ...orphan, error: error.message });
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ticket = await ticketGate.acquire(orphan.triageRecordId, 'alerted');
|
|
139
|
+
if (ticket === 'taken') { skipped += 1; continue; }
|
|
140
|
+
if (ticket === 'unavailable') {
|
|
141
|
+
skipped += 1;
|
|
142
|
+
logger.error('[recommendationRecovery] triage produced no recommendations and could not be claimed', { ...orphan, ageMs });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
logger.error('[recommendationRecovery] triage produced no recommendations', {
|
|
147
|
+
...orphan, ageMs, retryable: fresh, retryLive: canRetry, botPaused: paused,
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
if (await alert(orphan)) {
|
|
151
|
+
alerted += 1;
|
|
152
|
+
} else {
|
|
153
|
+
await ticketGate.release(orphan.triageRecordId);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await ticketGate.release(orphan.triageRecordId);
|
|
157
|
+
logger.error('[recommendationRecovery] could not file the ops ticket', { ...orphan, error: error.message });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { scanned: orphans.length, retried, alerted, skipped };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
botIsPaused,
|
|
166
|
+
sweepUnrecoveredTriages,
|
|
167
|
+
orphanFormula,
|
|
168
|
+
_handledClaims: retryClaims,
|
|
169
|
+
_ticketClaims: ticketClaims,
|
|
170
|
+
CLAIM_TTL_MS,
|
|
171
|
+
MIN_AGE_MS,
|
|
172
|
+
RETRY_MAX_AGE_MS,
|
|
173
|
+
MAX_CANDIDATES,
|
|
174
|
+
MAX_RETRIES_PER_PASS,
|
|
175
|
+
};
|
|
@@ -55,6 +55,34 @@ class WriteClaimStore {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
async acquire(key, value = true) {
|
|
59
|
+
if (this.memory.get(key) !== undefined) return 'taken';
|
|
60
|
+
if (!this._durable()) return 'unavailable';
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await WriteClaim.create({ key: this._key(key), value, expiresAt: new Date(Date.now() + this.ttlMs) });
|
|
64
|
+
this.memory.set(key, value);
|
|
65
|
+
return 'acquired';
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === 11000) {
|
|
68
|
+
this.memory.set(key, value);
|
|
69
|
+
return 'taken';
|
|
70
|
+
}
|
|
71
|
+
logger.warn('[writeClaimStore] Claim could not be acquired', { namespace: this.namespace, error: error.message });
|
|
72
|
+
return 'unavailable';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async release(key) {
|
|
77
|
+
this.memory.delete(key);
|
|
78
|
+
if (!this._durable()) return;
|
|
79
|
+
try {
|
|
80
|
+
await WriteClaim.deleteOne({ key: this._key(key) });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.warn('[writeClaimStore] Claim could not be released', { namespace: this.namespace, error: error.message });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
clear() {
|
|
59
87
|
this.memory.clear();
|
|
60
88
|
}
|
package/lib/config/lifecycle.js
CHANGED
|
@@ -3,11 +3,7 @@ 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');
|
|
7
6
|
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');
|
|
11
7
|
const { _clearRecentIntakeCache } = require('../clinical/services/clinicalAirtableService');
|
|
12
8
|
const { _clearExtractorConfigCache } = require('../clinical/services/clinicalExtractionService');
|
|
13
9
|
const { _resetRegistry } = require('../clinical/services/assistantResolver');
|
|
@@ -18,10 +14,6 @@ function resetAll() {
|
|
|
18
14
|
_resetRegistry();
|
|
19
15
|
_resetLLMState();
|
|
20
16
|
setPreprocessingHandler(null);
|
|
21
|
-
clearInteractiveRoutes();
|
|
22
|
-
_resetSafetyNetConfig();
|
|
23
|
-
_resetSafetyNetState();
|
|
24
|
-
stopSafetyNetSweep();
|
|
25
17
|
_resetOverrides();
|
|
26
18
|
_resetMetaConfig();
|
|
27
19
|
_clearClinicalContextCache();
|
|
@@ -1,100 +1,35 @@
|
|
|
1
|
-
const { Symptoms_ID } = require('../config/airtableConfig');
|
|
2
1
|
const { logger } = require('../utils/logger');
|
|
3
|
-
const { getRecordByFilter } = require('../services/airtableService');
|
|
4
2
|
const { handleTriageCompleted } = require('../clinical/services/triageRecommendationService');
|
|
5
|
-
const {
|
|
3
|
+
const { buildRetriggerPayload } = require('../clinical/helpers/triageRetriggerHelper');
|
|
6
4
|
const { TRIAGE_RUN_PREFIX } = require('../fhir/helpers/recommendationIds');
|
|
7
5
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const filter = `OR(${ids.map((id) => `RECORD_ID()='${id}'`).join(',')})`;
|
|
20
|
-
let rows;
|
|
21
|
-
try {
|
|
22
|
-
rows = await getRecordByFilter(Symptoms_ID, ITEM_TABLE, filter, null, [ITEM_FIELD], true);
|
|
23
|
-
} catch {
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
for (const row of (Array.isArray(rows) ? rows : [])) byId.set(row._recordId, row[ITEM_FIELD]);
|
|
27
|
-
}
|
|
28
|
-
for (const [field, value] of Object.entries(links)) {
|
|
29
|
-
const linked = (Array.isArray(value) ? value : []).length;
|
|
30
|
-
const wanted = linkIdsOf(value);
|
|
31
|
-
const items = wanted.map((id) => byId.get(id)).filter((item) => item != null && String(item).trim() !== '');
|
|
32
|
-
if (items.length !== linked) {
|
|
33
|
-
logger.warn('[TriageRecommendationController] unresolved triage item links', {
|
|
34
|
-
triageRecordId, field, linked, matchedIdShape: wanted.length, resolved: items.length,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
resolved[field] = items;
|
|
38
|
-
}
|
|
39
|
-
return resolved;
|
|
40
|
-
}
|
|
6
|
+
const STATUS_BY_REASON = {
|
|
7
|
+
invalid_triage_id: 400,
|
|
8
|
+
missing_code: 400,
|
|
9
|
+
no_patient_link: 400,
|
|
10
|
+
code_mismatch: 400,
|
|
11
|
+
triage_not_found: 404,
|
|
12
|
+
patient_not_found: 404,
|
|
13
|
+
triage_read_failed: 503,
|
|
14
|
+
patient_read_failed: 503,
|
|
15
|
+
item_read_failed: 503,
|
|
16
|
+
};
|
|
41
17
|
|
|
42
18
|
const triageRecommendationController = async (req, res) => {
|
|
43
19
|
const { triage_record_id: triageRecordId, code } = req.body || {};
|
|
44
|
-
if (typeof triageRecordId !== 'string' || !RECORD_ID_RX.test(triageRecordId.trim())) {
|
|
45
|
-
return res.status(400).json({ success: false, error: 'triage_record_id must be an Airtable record id' });
|
|
46
|
-
}
|
|
47
|
-
if (!code || typeof code !== 'string') {
|
|
48
|
-
return res.status(400).json({ success: false, error: 'code is required' });
|
|
49
|
-
}
|
|
50
20
|
|
|
51
21
|
try {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
22
|
+
const built = await buildRetriggerPayload({ triageRecordId, code });
|
|
23
|
+
if (!built.ok) {
|
|
24
|
+
const status = STATUS_BY_REASON[built.reason] ?? 500;
|
|
25
|
+
if (status === 503) {
|
|
26
|
+
logger.error('[TriageRecommendationController] retrigger read failed', { triageRecordId, code, reason: built.reason });
|
|
27
|
+
}
|
|
28
|
+
return res.status(status).json({ success: false, error: built.message });
|
|
58
29
|
}
|
|
59
|
-
const row = rows[0];
|
|
60
|
-
if (!row) return res.status(404).json({ success: false, error: 'triage record not found' });
|
|
61
30
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return res.status(400).json({ success: false, error: 'triage row has no patient link' });
|
|
65
|
-
}
|
|
66
|
-
const patients = await getRecordByFilter(Symptoms_ID, 'estado_general', `RECORD_ID()='${patientLink}'`)
|
|
67
|
-
.catch(() => undefined);
|
|
68
|
-
if (!Array.isArray(patients)) {
|
|
69
|
-
logger.error('[TriageRecommendationController] patient record read failed', { triageRecordId: safeId, code });
|
|
70
|
-
return res.status(503).json({ success: false, error: 'patient record read failed' });
|
|
71
|
-
}
|
|
72
|
-
if (!patients[0]) {
|
|
73
|
-
return res.status(404).json({ success: false, error: 'patient record not found' });
|
|
74
|
-
}
|
|
75
|
-
if (patients[0].whatsapp_id !== code) {
|
|
76
|
-
return res.status(400).json({ success: false, error: 'code does not match the triage row patient' });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const cadence = row['type_quest (from questionnaire_id)'];
|
|
80
|
-
const linkedItems = await resolveLinkedItems({
|
|
81
|
-
additional_symptoms_select: row.additional_symptoms_select,
|
|
82
|
-
pain_multiselect: row.pain_multiselect,
|
|
83
|
-
}, safeId);
|
|
84
|
-
if (!linkedItems) {
|
|
85
|
-
logger.error('[TriageRecommendationController] triage item read failed', { triageRecordId: safeId, code });
|
|
86
|
-
return res.status(503).json({ success: false, error: 'triage item read failed' });
|
|
87
|
-
}
|
|
88
|
-
const {
|
|
89
|
-
additional_symptoms_select: additionalSymptoms,
|
|
90
|
-
pain_multiselect: painOptions,
|
|
91
|
-
} = linkedItems;
|
|
92
|
-
const payload = buildPayloadFromTriageRow(
|
|
93
|
-
{ ...row, type_quest: Array.isArray(cadence) ? cadence[0] : cadence ?? row.type_quest },
|
|
94
|
-
{ patientCode: code, triageRecordId: safeId, additionalSymptoms, painOptions },
|
|
95
|
-
);
|
|
96
|
-
handleTriageCompleted(payload, { trigger: 'retry' });
|
|
97
|
-
return res.status(202).json({ success: true, accepted: true, run_id: `${TRIAGE_RUN_PREFIX}${safeId}` });
|
|
31
|
+
handleTriageCompleted(built.payload, { trigger: 'retry' });
|
|
32
|
+
return res.status(202).json({ success: true, accepted: true, run_id: `${TRIAGE_RUN_PREFIX}${String(triageRecordId).trim()}` });
|
|
98
33
|
} catch (error) {
|
|
99
34
|
logger.error('[TriageRecommendationController] retrigger error', { error: error.message, triageRecordId, code });
|
|
100
35
|
return res.status(500).json({ success: false, error: 'Failed to retrigger triage recommendation' });
|
|
@@ -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 { sweepUnrecoveredTriages } = require('../clinical/services/recommendationRecoveryService');
|
|
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,7 +46,6 @@ 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');
|
|
48
|
-
const { invokeInteractiveRoute } = require('./interactiveRouteService');
|
|
49
49
|
|
|
50
50
|
const { PhiProcessor } = require('./PhiProcessor');
|
|
51
51
|
|
|
@@ -170,6 +170,7 @@ class NexusMessaging {
|
|
|
170
170
|
...(config.queueReconciliation || {}),
|
|
171
171
|
scheduleMessage: (options) => this.scheduledMessageJob.schedule(options),
|
|
172
172
|
scheduleTemplateApproval: (options) => this.scheduleTemplateApproval(options),
|
|
173
|
+
sweepUnrecoveredTriages: () => sweepUnrecoveredTriages(),
|
|
173
174
|
sweepHeldMessages: () => sweepHeldMessages()
|
|
174
175
|
});
|
|
175
176
|
|
|
@@ -581,7 +582,6 @@ class NexusMessaging {
|
|
|
581
582
|
}
|
|
582
583
|
|
|
583
584
|
async handleInteractive(messageData) {
|
|
584
|
-
await invokeInteractiveRoute(messageData);
|
|
585
585
|
if (this.handlers.onInteractive) return await this.handlers.onInteractive(messageData, this);
|
|
586
586
|
}
|
|
587
587
|
|
|
@@ -14,7 +14,6 @@ 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'];
|
|
18
17
|
const URGENT_CASE_TYPES = ['Urgencia', 'Emergencia'];
|
|
19
18
|
|
|
20
19
|
const isRequestOpen = (status) => !CLOSED_REQUEST_STATUSES.includes(status);
|
|
@@ -41,7 +40,6 @@ module.exports = {
|
|
|
41
40
|
ATTENTION_CASE_WINDOW_DAYS,
|
|
42
41
|
ATTENTION_OPERATIONS_STATUS,
|
|
43
42
|
ATTENTION_REQUEST_STATUS,
|
|
44
|
-
REQUEST_URGENCIES,
|
|
45
43
|
isCaseOpen,
|
|
46
44
|
isCaseUrgent,
|
|
47
45
|
isRequestOpen,
|
package/lib/index.d.ts
CHANGED
|
@@ -22,14 +22,8 @@ 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
|
-
|
|
31
25
|
export interface InteractiveData {
|
|
32
|
-
type: 'button' | 'list' | 'flow'
|
|
26
|
+
type: 'button' | 'list' | 'flow';
|
|
33
27
|
payload?: string;
|
|
34
28
|
title?: string;
|
|
35
29
|
description?: string;
|
package/lib/index.js
CHANGED
|
@@ -26,10 +26,6 @@ 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');
|
|
33
29
|
const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
|
|
34
30
|
const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
|
|
35
31
|
const { createWorkflowRunner } = require('./core/workflowRunner');
|
|
@@ -188,16 +184,6 @@ class Nexus {
|
|
|
188
184
|
logger.warn('Warning: failed to register clinical tools:', e?.message || e);
|
|
189
185
|
}
|
|
190
186
|
|
|
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
|
-
|
|
201
187
|
await this.messaging.initializeLlmMonitor();
|
|
202
188
|
|
|
203
189
|
this.isInitialized = true;
|
|
@@ -252,7 +238,6 @@ class Nexus {
|
|
|
252
238
|
}
|
|
253
239
|
|
|
254
240
|
async shutdown({ drainMs, abortGraceMs, teardownMs, closeStorage = true } = {}) {
|
|
255
|
-
stopSafetyNetSweep();
|
|
256
241
|
if (this._shutdownPromise) return this._shutdownPromise;
|
|
257
242
|
this._shutdownPromise = (async () => {
|
|
258
243
|
const result = await this.messaging.shutdown({ drainMs, abortGraceMs, teardownMs });
|
|
@@ -295,8 +280,6 @@ module.exports = {
|
|
|
295
280
|
setPreprocessingHandler,
|
|
296
281
|
hasPreprocessingHandler,
|
|
297
282
|
invokePreprocessingHandler,
|
|
298
|
-
registerInteractiveRoute,
|
|
299
|
-
clearInteractiveRoutes,
|
|
300
283
|
routes,
|
|
301
284
|
syncClinicalContext,
|
|
302
285
|
runProactiveOutreachSweep,
|
|
@@ -4,7 +4,6 @@ const { httpError } = require('../utils/httpErrorUtils');
|
|
|
4
4
|
|
|
5
5
|
const { escapeFormulaValue, fieldEquals, recordIdIn } = require('../helpers/airtableFormulaHelper');
|
|
6
6
|
const {
|
|
7
|
-
REQUEST_URGENCIES,
|
|
8
7
|
isCaseOpen,
|
|
9
8
|
isCaseUrgent,
|
|
10
9
|
isRequestOpen,
|
|
@@ -110,7 +109,6 @@ const TASK_KINDS = {
|
|
|
110
109
|
toTask: toRequest,
|
|
111
110
|
writable: {
|
|
112
111
|
status: { field: 'status', options: REQUEST_STATUSES },
|
|
113
|
-
urgency: { field: 'urgency', options: REQUEST_URGENCIES },
|
|
114
112
|
note: { field: 'Observaciones' },
|
|
115
113
|
},
|
|
116
114
|
},
|
|
@@ -172,7 +170,7 @@ async function getPatientTasks(code) {
|
|
|
172
170
|
requests: requests.attentionCount,
|
|
173
171
|
isUrgent: requests.isUrgent || cases.isUrgent,
|
|
174
172
|
},
|
|
175
|
-
options: { requestStatus: REQUEST_STATUSES,
|
|
173
|
+
options: { requestStatus: REQUEST_STATUSES, caseStatus: CASE_STATUSES, operationsStatus: OPERATIONS_STATUSES },
|
|
176
174
|
};
|
|
177
175
|
}
|
|
178
176
|
|
|
@@ -7,20 +7,24 @@ const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
|
|
|
7
7
|
const DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
|
8
8
|
const DEFAULT_STALE_AFTER_MS = 30 * 60 * 1000;
|
|
9
9
|
const DEFAULT_BATCH_LIMIT = 200;
|
|
10
|
+
const DEFAULT_PASS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
10
11
|
|
|
11
12
|
class QueueReconciliationService {
|
|
12
13
|
constructor({
|
|
13
14
|
scheduleMessage,
|
|
14
15
|
scheduleTemplateApproval,
|
|
15
16
|
sweepHeldMessages,
|
|
17
|
+
sweepUnrecoveredTriages,
|
|
16
18
|
intervalMs = DEFAULT_INTERVAL_MS,
|
|
17
19
|
lookbackMs = DEFAULT_LOOKBACK_MS,
|
|
18
20
|
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
|
19
|
-
batchLimit = DEFAULT_BATCH_LIMIT
|
|
21
|
+
batchLimit = DEFAULT_BATCH_LIMIT,
|
|
22
|
+
passTimeoutMs = DEFAULT_PASS_TIMEOUT_MS
|
|
20
23
|
} = {}) {
|
|
21
24
|
this.scheduleMessage = scheduleMessage;
|
|
22
25
|
this.scheduleTemplateApproval = scheduleTemplateApproval;
|
|
23
26
|
this.sweepHeldMessages = sweepHeldMessages;
|
|
27
|
+
this.sweepUnrecoveredTriages = sweepUnrecoveredTriages;
|
|
24
28
|
this.intervalMs = intervalMs;
|
|
25
29
|
this.lookbackMs = lookbackMs;
|
|
26
30
|
this.staleAfterMs = staleAfterMs;
|
|
@@ -28,6 +32,7 @@ class QueueReconciliationService {
|
|
|
28
32
|
this.timer = null;
|
|
29
33
|
this.stopped = false;
|
|
30
34
|
this.inFlight = new Set();
|
|
35
|
+
this.passTimeoutMs = passTimeoutMs;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
start() {
|
|
@@ -35,10 +40,15 @@ class QueueReconciliationService {
|
|
|
35
40
|
this.stopped = false;
|
|
36
41
|
|
|
37
42
|
const launch = (label) => {
|
|
38
|
-
const
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
for (const entry of this.inFlight) {
|
|
45
|
+
if (now - entry.startedAt < this.passTimeoutMs) return;
|
|
46
|
+
}
|
|
47
|
+
const entry = { startedAt: now };
|
|
48
|
+
entry.promise = this.runOnce()
|
|
39
49
|
.catch((error) => logger.warn(`[QueueReconciliation] ${label} failed`, { error: error.message }))
|
|
40
|
-
.finally(() => this.inFlight.delete(
|
|
41
|
-
this.inFlight.add(
|
|
50
|
+
.finally(() => this.inFlight.delete(entry));
|
|
51
|
+
this.inFlight.add(entry);
|
|
42
52
|
};
|
|
43
53
|
|
|
44
54
|
launch('Startup pass');
|
|
@@ -56,23 +66,35 @@ class QueueReconciliationService {
|
|
|
56
66
|
clearInterval(this.timer);
|
|
57
67
|
this.timer = null;
|
|
58
68
|
}
|
|
59
|
-
return Promise.allSettled([...this.inFlight]).then(() => undefined);
|
|
69
|
+
return Promise.allSettled([...this.inFlight].map((entry) => entry.promise)).then(() => undefined);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
async runOnce() {
|
|
63
|
-
if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0 };
|
|
73
|
+
if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0, unrecoveredTriages: 0 };
|
|
64
74
|
const since = new Date(Date.now() - this.lookbackMs);
|
|
65
|
-
const [scheduledMessages, recoveries, heldMessages] = await Promise.all([
|
|
75
|
+
const [scheduledMessages, recoveries, heldMessages, unrecoveredTriages] = await Promise.all([
|
|
66
76
|
this._reEnqueueScheduledMessages(since),
|
|
67
77
|
this._reArmTemplateApprovals(since),
|
|
68
|
-
this._releaseHeldMessages()
|
|
78
|
+
this._releaseHeldMessages(),
|
|
79
|
+
this._recoverUnrecoveredTriages()
|
|
69
80
|
]);
|
|
70
81
|
|
|
71
82
|
if (scheduledMessages || recoveries || heldMessages) {
|
|
72
83
|
logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages });
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
return { scheduledMessages, recoveries, heldMessages };
|
|
86
|
+
return { scheduledMessages, recoveries, heldMessages, unrecoveredTriages };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async _recoverUnrecoveredTriages() {
|
|
90
|
+
if (typeof this.sweepUnrecoveredTriages !== 'function') return 0;
|
|
91
|
+
try {
|
|
92
|
+
const result = await this.sweepUnrecoveredTriages();
|
|
93
|
+
return (result?.retried || 0) + (result?.alerted || 0);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.error('[QueueReconciliation] Recommendation recovery sweep failed', { error: error.message });
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
async _releaseHeldMessages() {
|
package/package.json
CHANGED
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,298 +0,0 @@
|
|
|
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 { clearInteractiveRoutes, normalizePayload: normalize, registerInteractiveRoute } = require('../../core/interactiveRouteService');
|
|
17
|
-
const { getMessages } = require('../../services/messageService');
|
|
18
|
-
const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
|
|
19
|
-
|
|
20
|
-
const { WriteClaimStore } = require('./writeClaimStore');
|
|
21
|
-
const { PAUSED_THREAD_DETAILS_PREFIX, recordUnresolvedRequest } = require('./clinicalAirtableService');
|
|
22
|
-
const { humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
|
|
23
|
-
|
|
24
|
-
const getMessaging = () => require('../../core/NexusMessaging');
|
|
25
|
-
|
|
26
|
-
const SOURCE = SAFETY_NET_SOURCE;
|
|
27
|
-
const PAYLOAD_PREFIX = 'peopl_safetynet_';
|
|
28
|
-
const CHOICES = ['urgent', 'callback', 'later'];
|
|
29
|
-
const REQUEST_CATEGORY = 'contact_team_member';
|
|
30
|
-
const CAN_WAIT_NOTE = 'El paciente indicó que su solicitud puede esperar.';
|
|
31
|
-
const ATTENDED_STATUS = 'sin atender';
|
|
32
|
-
|
|
33
|
-
const REQUEST_COPY = {
|
|
34
|
-
urgent: {
|
|
35
|
-
question: 'El paciente indicó que su situación es urgente',
|
|
36
|
-
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.`
|
|
37
|
-
},
|
|
38
|
-
callback: {
|
|
39
|
-
question: 'El paciente pidió que el equipo lo contacte hoy',
|
|
40
|
-
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.`
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
let offerClaimStore = null;
|
|
45
|
-
let tapClaimStore = null;
|
|
46
|
-
let phoneCheckedFor = null;
|
|
47
|
-
|
|
48
|
-
const offerClaims = () => {
|
|
49
|
-
if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
|
|
50
|
-
return offerClaimStore;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const tapClaims = () => {
|
|
54
|
-
if (!tapClaimStore) tapClaimStore = new WriteClaimStore({ namespace: 'safety-net-choice', ttlMs: getTapWindowMs() });
|
|
55
|
-
return tapClaimStore;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const disarmSafetyNet = () => {
|
|
59
|
-
clearInteractiveRoutes();
|
|
60
|
-
stopSafetyNetSweep();
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const _resetSafetyNetState = () => {
|
|
64
|
-
offerClaimStore = null;
|
|
65
|
-
tapClaimStore = null;
|
|
66
|
-
phoneCheckedFor = null;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
function matchSafetyNetChoice(payload, title) {
|
|
70
|
-
const candidate = normalize(payload);
|
|
71
|
-
if (candidate.startsWith(PAYLOAD_PREFIX)) {
|
|
72
|
-
const suffix = candidate.slice(PAYLOAD_PREFIX.length);
|
|
73
|
-
return CHOICES.find((choice) => choice === suffix) || null;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const label = normalize(title) || candidate;
|
|
77
|
-
if (!label) return null;
|
|
78
|
-
return CHOICES.find((choice) => {
|
|
79
|
-
const configured = normalize(getSafetyNetLabel(choice));
|
|
80
|
-
return configured && configured === label;
|
|
81
|
-
}) || null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
85
|
-
const phone = getSafetyNetPhone();
|
|
86
|
-
if (!phone || phoneCheckedFor === contentSid) return;
|
|
87
|
-
phoneCheckedFor = contentSid;
|
|
88
|
-
try {
|
|
89
|
-
const body = await getMessaging().requireProvider().renderTemplate(contentSid);
|
|
90
|
-
const digits = (value) => String(value ?? '').replace(/\D/g, '');
|
|
91
|
-
if (body && !digits(body).includes(digits(phone))) {
|
|
92
|
-
logger.error('[PausedSafetyNet] The configured phone number is absent from the template body', { contentSid });
|
|
93
|
-
}
|
|
94
|
-
} catch (error) {
|
|
95
|
-
phoneCheckedFor = null;
|
|
96
|
-
logger.warn('[PausedSafetyNet] Could not read the template to check the phone number', { contentSid, error: error.message });
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function offerSafetyNet({ code }) {
|
|
101
|
-
try {
|
|
102
|
-
const contentSid = getSafetyNetContentSid();
|
|
103
|
-
if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
104
|
-
if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
|
|
105
|
-
if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
|
|
106
|
-
|
|
107
|
-
await warnIfPhoneDiffersFromTemplate(contentSid);
|
|
108
|
-
|
|
109
|
-
const result = await getMessaging().sendMessage({ code, contentSid, triggeredBy: SOURCE });
|
|
110
|
-
const sent = result?.success === true || result?.completed === true;
|
|
111
|
-
if (!sent) return { sent: false, reason: result?.status || 'send_failed' };
|
|
112
|
-
|
|
113
|
-
await offerClaims().set(code, true);
|
|
114
|
-
return { sent: true, reason: null };
|
|
115
|
-
} catch (error) {
|
|
116
|
-
logger.error('[PausedSafetyNet] Could not offer the safety net', { code, error: error.message });
|
|
117
|
-
return { sent: false, reason: 'error' };
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function offerIsLive(code) {
|
|
122
|
-
try {
|
|
123
|
-
const since = new Date(Date.now() - getOfferWindowMs());
|
|
124
|
-
const [sent] = await getMessages(
|
|
125
|
-
{ numero: code, from_me: true, content_sid: getSafetyNetContentSid(), createdAt: { $gt: since } },
|
|
126
|
-
{ sort: { createdAt: -1 }, limit: 1 }
|
|
127
|
-
);
|
|
128
|
-
return Boolean(sent);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
logger.error('[PausedSafetyNet] Could not read the offer history; honouring the tap', { code, error: error.message });
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function fileUrgentRequest(code, choice) {
|
|
136
|
-
const copy = REQUEST_COPY[choice];
|
|
137
|
-
return recordUnresolvedRequest({
|
|
138
|
-
code,
|
|
139
|
-
trace: null,
|
|
140
|
-
question: copy.question,
|
|
141
|
-
details: copy.details(code),
|
|
142
|
-
category: REQUEST_CATEGORY,
|
|
143
|
-
urgency: 'urgent'
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function markCanWait(code) {
|
|
148
|
-
const { requests = [] } = await getPatientTasks(code);
|
|
149
|
-
const target = requests.find((request) => request.status === ATTENDED_STATUS
|
|
150
|
-
&& request.category === REQUEST_CATEGORY
|
|
151
|
-
&& String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
|
|
152
|
-
if (!target) return false;
|
|
153
|
-
|
|
154
|
-
await updatePatientTask({
|
|
155
|
-
code,
|
|
156
|
-
kind: 'requests',
|
|
157
|
-
recordId: target.recordId,
|
|
158
|
-
changes: {
|
|
159
|
-
urgency: 'routine',
|
|
160
|
-
note: [target.note, CAN_WAIT_NOTE].filter(Boolean).join('\n')
|
|
161
|
-
},
|
|
162
|
-
updatedBy: SOURCE,
|
|
163
|
-
source: SOURCE
|
|
164
|
-
});
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function applyChoice(code, choice) {
|
|
169
|
-
if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
|
|
170
|
-
|
|
171
|
-
const queued = await fileUrgentRequest(code, choice);
|
|
172
|
-
if (choice !== 'callback') return { queued, pledge: null };
|
|
173
|
-
if (!queued) return { queued, pledge: null };
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
return { queued, pledge: await openPledge({ code }) };
|
|
177
|
-
} catch (error) {
|
|
178
|
-
logger.error('[PausedSafetyNet] The request was filed but no pledge was opened', { code, error: error.message });
|
|
179
|
-
return { queued, pledge: null };
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const withPhone = (text, closing) => {
|
|
184
|
-
const phone = getSafetyNetPhone();
|
|
185
|
-
return phone ? `${text} ${closing.replace('{phone}', phone)}` : text;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
function ackFor(choice, outcome) {
|
|
189
|
-
if (outcome.stale) {
|
|
190
|
-
return withPhone('Recibimos tu respuesta y el equipo va a revisar tu conversación.', 'Si necesitas ayuda ahora, llámanos al {phone}.');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (!outcome.queued) {
|
|
194
|
-
return withPhone('No pudimos registrar tu solicitud.', 'Por favor llámanos al {phone} para que te atendamos.');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (choice === 'urgent') {
|
|
198
|
-
return withPhone('Entendido, ya avisamos al equipo.', 'Si es una urgencia médica, llámanos ahora al {phone}.');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (choice === 'later') {
|
|
202
|
-
return outcome.downgraded
|
|
203
|
-
? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
|
|
204
|
-
: withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (!outcome.pledge) {
|
|
208
|
-
return withPhone('Listo, ya avisamos al equipo y te van a contactar.', 'Si te sientes peor antes, llámanos al {phone}.');
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (outcome.pledge.deferredToWindow) {
|
|
212
|
-
return withPhone(
|
|
213
|
-
`Listo, ya avisamos al equipo. Te van a contactar en cuanto abran, a partir de las ${CONTACT_WINDOW.startHour}:00.`,
|
|
214
|
-
'Si es una urgencia médica, llámanos ahora al {phone}.'
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const minutes = Math.round(getPledgeMs() / 60000);
|
|
219
|
-
return withPhone(
|
|
220
|
-
`Listo. Un miembro del equipo te va a contactar en los próximos ${minutes} minutos.`,
|
|
221
|
-
'Si te sientes peor antes, llámanos al {phone}.'
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function sendAck(code, choice, outcome) {
|
|
226
|
-
try {
|
|
227
|
-
await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
|
|
228
|
-
return true;
|
|
229
|
-
} catch (error) {
|
|
230
|
-
logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function handleSafetyNetTap({ code, payload, title }) {
|
|
236
|
-
const choice = matchSafetyNetChoice(payload, title);
|
|
237
|
-
if (!choice || !code) return { handled: false };
|
|
238
|
-
|
|
239
|
-
const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
|
|
240
|
-
if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
|
|
241
|
-
|
|
242
|
-
let acknowledged = false;
|
|
243
|
-
try {
|
|
244
|
-
if (!await offerIsLive(code)) {
|
|
245
|
-
acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
246
|
-
return { handled: true, choice, queued: false, stale: true };
|
|
247
|
-
}
|
|
248
|
-
await offerClaims().set(code, true);
|
|
249
|
-
|
|
250
|
-
const chosen = await tapClaims().get(code);
|
|
251
|
-
const repeated = Boolean(chosen);
|
|
252
|
-
const effective = repeated ? chosen : choice;
|
|
253
|
-
|
|
254
|
-
let outcome = { queued: true, pledge: null, repeated };
|
|
255
|
-
if (!repeated) {
|
|
256
|
-
try {
|
|
257
|
-
outcome = { ...await applyChoice(code, choice), repeated: false };
|
|
258
|
-
await tapClaims().set(code, choice);
|
|
259
|
-
} catch (error) {
|
|
260
|
-
logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
|
|
261
|
-
outcome = { queued: false, pledge: null, repeated: false };
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
acknowledged = await sendAck(code, effective, outcome);
|
|
266
|
-
return { handled: true, choice: effective, ...outcome };
|
|
267
|
-
} finally {
|
|
268
|
-
if (acknowledged) await completeSendClaim(claim.claimId);
|
|
269
|
-
else await releaseSendClaim(claim.claimId);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function registerSafetyNetRoutes() {
|
|
274
|
-
const armed = Boolean(getSafetyNetContentSid()) && Boolean(getSafetyNetPhone());
|
|
275
|
-
if (!armed) {
|
|
276
|
-
if (getSafetyNetContentSid()) logger.error('[PausedSafetyNet] A template is configured without a phone number; the safety net stays disarmed');
|
|
277
|
-
disarmSafetyNet();
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
|
|
282
|
-
for (const choice of CHOICES) {
|
|
283
|
-
const label = getSafetyNetLabel(choice);
|
|
284
|
-
if (label) registerInteractiveRoute(label, handleSafetyNetTap);
|
|
285
|
-
}
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
module.exports = {
|
|
290
|
-
disarmSafetyNet,
|
|
291
|
-
offerSafetyNet,
|
|
292
|
-
handleSafetyNetTap,
|
|
293
|
-
matchSafetyNetChoice,
|
|
294
|
-
registerSafetyNetRoutes,
|
|
295
|
-
_resetSafetyNetState,
|
|
296
|
-
PAYLOAD_PREFIX,
|
|
297
|
-
CHOICES
|
|
298
|
-
};
|
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,20 +0,0 @@
|
|
|
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 };
|