@peopl-health/nexus 6.4.0-dev.18779 → 6.4.0-dev.18830
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/helpers/doctorPlanHelper.js +2 -8
- package/lib/clinical/helpers/planTextHelper.js +4 -10
- package/lib/clinical/services/doctorPlanService.js +0 -4
- package/lib/clinical/services/treatmentProposalService.js +2 -3
- package/lib/config/recoveryTemplateConfig.js +31 -0
- package/lib/core/NexusMessaging.js +9 -5
- package/lib/helpers/templateRecoveryHelper.js +101 -1
- package/lib/jobs/TemplateApprovalJob.js +39 -13
- package/lib/models/deliveryAttemptModel.js +1 -0
- package/lib/services/assistantMessageService.js +1 -1
- package/lib/services/escalationReviewService.js +4 -7
- package/lib/services/heldMessageService.js +2 -0
- package/package.json +1 -1
|
@@ -20,7 +20,6 @@ const FOLLOW_UP_LINES = {
|
|
|
20
20
|
vigilancia: (hours) => `Seguimiento: queda en vigilancia las próximas ${hours} horas; le escribiremos para ver cómo va.`,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
// What the assistant is told to pass on; it writes the patient message itself.
|
|
24
23
|
function planInstructions(plan) {
|
|
25
24
|
const lines = ['Transmítele al paciente lo que indicó el médico, con tus palabras: no cambies dosis, frecuencia ni días, y no agregues otros medicamentos ni estudios.'];
|
|
26
25
|
if (plan.items.length) {
|
|
@@ -42,7 +41,6 @@ function assistantInstructionsOf(plan) {
|
|
|
42
41
|
return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
// What the team's case shows as the indication, in the doctor's terms rather than the assistant's.
|
|
46
44
|
function planSummary(plan) {
|
|
47
45
|
const lines = [...plan.items.map(itemLine), ...plan.studies.map((study) => `Estudio: ${studyLine(study)}`)];
|
|
48
46
|
if (plan.homeCare.length) lines.push(`Cuidados en casa: ${plan.homeCare.join('; ')}.`);
|
|
@@ -50,30 +48,26 @@ function planSummary(plan) {
|
|
|
50
48
|
return lines.join('\n');
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
// The receta the Airtable automations turn into the doctor's PDF.
|
|
54
51
|
function prescriptionText(plan) {
|
|
55
52
|
const lines = plan.items.map(itemLine);
|
|
56
53
|
if (plan.studies.length) lines.push('Estudios:', ...plan.studies.map(studyLine));
|
|
57
54
|
return lines.join('\n');
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
// «1 tableta cada 24 horas por 5 días; además, …» → dose, frequency, days and the rest; what does not match is left for the doctor.
|
|
61
57
|
function dosingOf(instructions) {
|
|
62
58
|
const text = oneLineOf(instructions);
|
|
63
59
|
const match = text.match(DOSING);
|
|
64
60
|
if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
|
|
65
61
|
const [, dose, frequency, duration, , , , rest] = match;
|
|
66
|
-
// A duration said after other directions («…, y tragar, por 7 días») is still the days.
|
|
67
62
|
const late = !duration ? rest?.match(LATE_DURATION) : null;
|
|
68
63
|
const said = duration ?? late?.[1];
|
|
69
64
|
const days = durationDays(said);
|
|
70
65
|
const others = late ? rest.slice(0, late.index).replace(/[,;\s]+$/, '') : rest;
|
|
71
|
-
// A range («por 3 a 5 días») is the doctor's to settle, so it stays in
|
|
66
|
+
// A range («por 3 a 5 días») is the doctor's to settle, so it stays in the note.
|
|
72
67
|
const left = [others, said && !days ? said : null].filter(Boolean).join(', ');
|
|
73
68
|
return { dose: isRouteOnly(dose) ? null : dose, frequency, days, rest: left || null };
|
|
74
69
|
}
|
|
75
70
|
|
|
76
|
-
// The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
|
|
77
71
|
// What the formulary adds after the dosing («además, valoración por oftalmología») goes to the note.
|
|
78
72
|
function proposedPlanOf(draft) {
|
|
79
73
|
const seen = new Set();
|
|
@@ -90,7 +84,7 @@ function proposedPlanOf(draft) {
|
|
|
90
84
|
return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
|
|
91
85
|
}
|
|
92
86
|
|
|
93
|
-
// The
|
|
87
|
+
// The note and home care count, so what the doctor wrote anywhere in the plan is never flagged.
|
|
94
88
|
const plannedTextOf = (plan) => [
|
|
95
89
|
...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
|
|
96
90
|
...plan.studies.flatMap((study) => [study.name, study.instructions]),
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
const { foldAccents } = require('../../utils/formatUtils');
|
|
2
2
|
const { normalizeForAnchor } = require('../../utils/textMatchUtils');
|
|
3
3
|
|
|
4
|
-
// Reading the Spanish of a doctor's plan and of the assistant's reply about it.
|
|
5
|
-
|
|
6
4
|
const NUMBER_WORDS = Object.freeze({
|
|
7
5
|
un: 1, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
|
|
8
6
|
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15, dieciseis: 16, dieciocho: 18, veinte: 20, veinticuatro: 24,
|
|
@@ -19,14 +17,12 @@ const DAY_UNITS = 'd[ií]as?|semanas?|mes(?:es)?|d\\b';
|
|
|
19
17
|
const DURATION_SOURCE = `(?:por|durante|x)\\s+(?:(?:unos|unas|los|las|pr[oó]ximos|otros)\\s+)*(${COUNT})(?:${TO}(${COUNT}))?\\s*(${DAY_UNITS})`;
|
|
20
18
|
const DURATION = new RegExp(`^${DURATION_SOURCE}$`, 'i');
|
|
21
19
|
|
|
22
|
-
// «VO», «v.o.», «vía oral», «por vía oral», «IV», «SL»…
|
|
23
20
|
const ROUTES = new Set(['vo', 'po', 'oral', 'iv', 'ev', 'intravenosa', 'im', 'intramuscular', 'sc', 'subcutanea', 'sl', 'sublingual', 'topica', 'rectal']);
|
|
24
21
|
// What a drug's name may carry that does not name it: «Ondansetrón 8 mg», «Metoclopramida gotas».
|
|
25
22
|
const NOT_A_NAME = /^(?:\d+(?:mg|ml|mcg|g|ui)?|mg|ml|mcg|g|ui|vo|iv|im|sc|sl|oral|tabletas?|capsulas?|comprimidos?|gotas|jarabe|crema|gel|unguento|solucion|suspension|sobres?|ampolletas?|ampulas?|parches?|spray|colirio)$/;
|
|
26
23
|
|
|
27
24
|
const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
|
|
28
|
-
//
|
|
29
|
-
// Only filler words may sit between the negation and the word, so «no es normal, ve a urgencias» still counts.
|
|
25
|
+
// Only filler may sit between: «no necesitas ir a urgencias» calms, «no es normal, ve a urgencias» does not.
|
|
30
26
|
const CALMING_BEFORE = /\b(?:no\s+(?:es|son|parece|hay|hace\s+falta|necesitas?|requiere|requieres|tienes\s+(?:que|por\s+que))|sin\s+necesidad\s+de)(?:\s+(?:ir|acudir|a|al|una|un|de|la|que))*\s*$/;
|
|
31
27
|
|
|
32
28
|
const folded = (text) => foldAccents(text).toLowerCase();
|
|
@@ -36,10 +32,9 @@ const daysPer = (unit) => DAYS_PER[folded(unit)];
|
|
|
36
32
|
|
|
37
33
|
const isRouteOnly = (text) => ROUTES.has(folded(text).replace(/\./g, '').replace(/^(?:por\s+)?(?:via\s+)?/, '').trim());
|
|
38
34
|
|
|
39
|
-
// The name in a drug line, without the dose or the form beside it.
|
|
40
35
|
const drugNameOf = (text) => normalizeForAnchor(text).split(' ').filter((word) => word && !NOT_A_NAME.test(word)).join(' ');
|
|
41
36
|
|
|
42
|
-
//
|
|
37
|
+
// Null for a range or an unknown count: the doctor settles those.
|
|
43
38
|
function durationDays(phrase) {
|
|
44
39
|
const match = String(phrase ?? '').trim().match(DURATION);
|
|
45
40
|
if (!match || match[2]) return null;
|
|
@@ -55,8 +50,7 @@ const TIMES_A_DAY = (written, ...counts) => {
|
|
|
55
50
|
return times.every((count) => count > 0 && 24 % count === 0) ? times.map((count) => `cada ${24 / count} horas`).join(' o ') : written;
|
|
56
51
|
};
|
|
57
52
|
|
|
58
|
-
//
|
|
59
|
-
// «3 veces al día» as «cada 8 horas», weeks and months as days. A dot stays decimal, as written in Mexico.
|
|
53
|
+
// A dot stays decimal, as written in Mexico.
|
|
60
54
|
function dosingTextOf(text) {
|
|
61
55
|
return folded(text)
|
|
62
56
|
.replace(/\b[1-9]\d{0,2}(?:,\d{3})+\b/g, (thousands) => thousands.replace(/,/g, ''))
|
|
@@ -84,7 +78,7 @@ const UNIT_SCALE = Object.freeze({
|
|
|
84
78
|
|
|
85
79
|
const valuesIn = (text, pattern) => new Set([...text.matchAll(pattern)].flatMap((match) => [match[1], match[2]]).filter(Boolean).map(Number));
|
|
86
80
|
|
|
87
|
-
//
|
|
81
|
+
// Amounts are keyed in mg, ml or UI, so «1 g» and «1000 mg» meet.
|
|
88
82
|
function dosingFactsOf(text) {
|
|
89
83
|
const dosing = dosingTextOf(text);
|
|
90
84
|
const amounts = new Map([...dosing.matchAll(AMOUNTS)].map(([written, value, unit]) => {
|
|
@@ -46,7 +46,6 @@ function itemOf(item, index) {
|
|
|
46
46
|
const name = `plan.items[${index}]`;
|
|
47
47
|
const position = textOf(item?.position, `${name}.position`)?.toUpperCase() ?? null;
|
|
48
48
|
const reason = textOf(item?.reason, `${name}.reason`);
|
|
49
|
-
// An alternative to the first line has to say why the first line does not fit.
|
|
50
49
|
if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
|
|
51
50
|
return {
|
|
52
51
|
drug: neededText(item?.drug, `${name}.drug`),
|
|
@@ -68,7 +67,6 @@ function followUpOf(value) {
|
|
|
68
67
|
return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
// The plan as the doctor approved it, refused whole when any line is incomplete.
|
|
72
70
|
function planOf(input) {
|
|
73
71
|
if (!isPlainObject(input)) throw httpError(400, 'plan is required');
|
|
74
72
|
const plan = {
|
|
@@ -90,7 +88,6 @@ function planOf(input) {
|
|
|
90
88
|
return plan;
|
|
91
89
|
}
|
|
92
90
|
|
|
93
|
-
// The doctor's edit wins; with none, the instructions follow the plan.
|
|
94
91
|
function approvedInstructions(plan, edited) {
|
|
95
92
|
if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
|
|
96
93
|
if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
|
|
@@ -101,7 +98,6 @@ function approvedInstructions(plan, edited) {
|
|
|
101
98
|
|
|
102
99
|
const strays = (said, planned) => [...said].filter((value) => !planned.has(value));
|
|
103
100
|
|
|
104
|
-
// What in the assistant's reply strays from the plan, for the team to check.
|
|
105
101
|
function replyIssues(plan, reply) {
|
|
106
102
|
if (!oneLineOf(reply)) return ['no se pudo leer el mensaje enviado'];
|
|
107
103
|
const planned = plannedTextOf(plan);
|
|
@@ -107,7 +107,7 @@ function drugsNamedIn(text) {
|
|
|
107
107
|
return DRUG_NAMES.filter((name) => padded.includes(` ${name} `));
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
110
|
+
// A brand of a planned drug counts as planned; a combination only when named whole.
|
|
111
111
|
function unplannedDrugsIn(text, plannedText) {
|
|
112
112
|
const planned = new Set(drugsNamedIn(plannedText));
|
|
113
113
|
const ownNames = (drug) => [drug.drug, ...String(drug.brand ?? '').split('/')].map(key).filter(Boolean);
|
|
@@ -136,8 +136,7 @@ function crossSymptomIssues(proposals) {
|
|
|
136
136
|
return issues;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//
|
|
140
|
-
// The dose or the form beside the name («Ondansetrón 8 mg», «(400 mg)») does not have to be said.
|
|
139
|
+
// A combination needs every part; the dose or form beside a name («Ondansetrón 8 mg») need not be said.
|
|
141
140
|
function isDrugNamed(text, drugName) {
|
|
142
141
|
const padded = ` ${key(text)} `;
|
|
143
142
|
const says = (name) => Boolean(name) && padded.includes(` ${name} `);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { logger } = require('../utils/logger');
|
|
2
|
+
const { MapCache } = require('../utils/MapCache');
|
|
3
|
+
const { getRecordByFilter } = require('../services/airtableService');
|
|
4
|
+
const { Config_ID } = require('./airtableConfig');
|
|
5
|
+
const { programConfigSource } = require('./programConfig');
|
|
6
|
+
|
|
7
|
+
const RECOVERY_TEMPLATES_TABLE = 'recovery_templates';
|
|
8
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
9
|
+
const CACHE_KEY = 'recoveryTemplates';
|
|
10
|
+
|
|
11
|
+
const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
12
|
+
|
|
13
|
+
// An unreadable table reads as empty, which keeps messages on their one-off templates.
|
|
14
|
+
async function getRecoveryTemplateConfig() {
|
|
15
|
+
const cached = cache.get(CACHE_KEY);
|
|
16
|
+
if (cached) return cached;
|
|
17
|
+
try {
|
|
18
|
+
const records = await getRecordByFilter(Config_ID, RECOVERY_TEMPLATES_TABLE, 'TRUE()');
|
|
19
|
+
const rows = (records || []).filter((row) => row.is_active && row.source === programConfigSource());
|
|
20
|
+
const config = Object.fromEntries(rows.map((row) => [row.config, row.value]));
|
|
21
|
+
cache.set(CACHE_KEY, config);
|
|
22
|
+
return config;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
logger.warn('[recoveryTemplateConfig] Could not read the fixed templates', { error: error.message });
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _resetRecoveryTemplateConfig() { cache.clear(); }
|
|
30
|
+
|
|
31
|
+
module.exports = { getRecoveryTemplateConfig, _resetRecoveryTemplateConfig };
|
|
@@ -402,14 +402,14 @@ class NexusMessaging {
|
|
|
402
402
|
|
|
403
403
|
let gate = null;
|
|
404
404
|
try {
|
|
405
|
-
gate = await reserveSend(messageData);
|
|
405
|
+
gate = messageData.skipContactGate ? null : await reserveSend(messageData);
|
|
406
406
|
if (gate?.blocked) {
|
|
407
407
|
await releaseSendClaim(claim.claimId);
|
|
408
408
|
return { success: false, messageId: null, status: 'contact_capped', reason: gate.reason, category: gate.category, delivered: false, deferred: false };
|
|
409
409
|
}
|
|
410
410
|
const result = await this._sendMessage(gate ? { ...messageData, contactGateDecisionId: gate.decisionId } : messageData);
|
|
411
|
-
// A deferred send is recorded when it actually leaves, so
|
|
412
|
-
if (result?.success !== true || result?.deferred === true) await releaseSend(gate?.decisionId);
|
|
411
|
+
// A deferred send is recorded when it actually leaves, and a nested send records itself, so the reservation must not also count.
|
|
412
|
+
if (result?.success !== true || result?.deferred === true || result?.sentByNestedSend) await releaseSend(gate?.decisionId);
|
|
413
413
|
if (result?.success === true && result?.deferred !== true && !await completeSendClaim(claim.claimId)) {
|
|
414
414
|
logger.error('[NexusMessaging] send claim completion failed; a retry after the claim expires may send again', {
|
|
415
415
|
code: messageData.code,
|
|
@@ -430,7 +430,7 @@ class NexusMessaging {
|
|
|
430
430
|
if (messageData.body) messageData.body = decodeUnicodeEscapes(messageData.body);
|
|
431
431
|
if (messageData.variables) messageData.variables = decodeUnicodeEscapesDeep(messageData.variables);
|
|
432
432
|
|
|
433
|
-
if (messageData.fileUrl && this._providerStores) await prepareMessageMedia(messageData);
|
|
433
|
+
if (messageData.fileUrl && !messageData.media?.key && this._providerStores) await prepareMessageMedia(messageData);
|
|
434
434
|
|
|
435
435
|
const storesTemplateMedia = Boolean(messageData.contentSid && !messageData.fileUrl && !messageData.parentMessageId && this.provider.renderTemplateContent);
|
|
436
436
|
if (messageData.contentSid && (!messageData.body || storesTemplateMedia)) {
|
|
@@ -484,6 +484,7 @@ class NexusMessaging {
|
|
|
484
484
|
if (!found) return { success: false, delivered: false, deferred: true };
|
|
485
485
|
const queued = await triggerTemplateRecovery(found, { source: 'preemptive' });
|
|
486
486
|
if (queued?.expired) return { ...NOT_SENT_EXPIRED, messageId: String(found._id) };
|
|
487
|
+
if (queued?.sentOnFixedTemplate) return { success: true, messageId: String(found._id), status: 'sent', delivered: false, deferred: false, sentByNestedSend: true };
|
|
487
488
|
return { success: true, messageId: String(found._id), status: 'queued', preemptive: true, delivered: false, deferred: true };
|
|
488
489
|
}
|
|
489
490
|
const saveFn = this.messageStorage.savePendingMessage.bind(this.messageStorage);
|
|
@@ -499,10 +500,13 @@ class NexusMessaging {
|
|
|
499
500
|
// triggerTemplateRecovery swallows its own errors and returns undefined. Reporting "queued"
|
|
500
501
|
// regardless is how a template that was never created still counts as an outreach, and the
|
|
501
502
|
// weekly guard then stops us ever trying again.
|
|
502
|
-
const queued = await triggerTemplateRecovery({ _id, body, numero: messageData.code, media: messageData.media, expiresAt: messageData.expiresAt }, { source: 'preemptive' });
|
|
503
|
+
const queued = await triggerTemplateRecovery({ _id, body, numero: messageData.code, media: messageData.media, expiresAt: messageData.expiresAt, contact_code: messageData.contactCode, contact_role: messageData.contactRole, triggeredBy: messageData.triggeredBy }, { source: 'preemptive' });
|
|
503
504
|
if (queued?.expired) {
|
|
504
505
|
return { ...NOT_SENT_EXPIRED, messageId: String(_id) };
|
|
505
506
|
}
|
|
507
|
+
if (queued?.sentOnFixedTemplate) {
|
|
508
|
+
return { success: true, messageId: String(_id), status: 'sent', delivered: false, deferred: false, sentByNestedSend: true };
|
|
509
|
+
}
|
|
506
510
|
if (!queued?.deferred) {
|
|
507
511
|
logger.error('[NexusMessaging] template recovery setup failed; message not queued', { code: messageData.code, messageId: String(_id) });
|
|
508
512
|
return { success: false, messageId: String(_id), status: 'failed', preemptive: true, delivered: false, deferred: false };
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
const { getRecoveryTemplateConfig } = require('../config/recoveryTemplateConfig');
|
|
1
2
|
const { generatePresignedUrl } = require('../config/awsConfig');
|
|
2
3
|
const { logger } = require('../utils/logger');
|
|
3
4
|
const { isAtOrBefore } = require('../utils/dateUtils');
|
|
4
5
|
const { createEvent, safeEmit } = require('../utils/eventUtils');
|
|
5
6
|
const { Message } = require('../models/messageModel');
|
|
7
|
+
const { DeliveryAttempt } = require('../models/deliveryAttemptModel');
|
|
6
8
|
const { Template: TemplateModel } = require('../models/templateModel');
|
|
7
9
|
const { Template } = require('../templates/templateStructure');
|
|
10
|
+
const { contactOf } = require('./caregiverHelper');
|
|
8
11
|
const { recordDeliveryAttempt } = require('./deliveryAttemptHelper');
|
|
9
12
|
|
|
10
13
|
const getMessaging = () => require('../core/NexusMessaging');
|
|
@@ -12,6 +15,16 @@ const getMessaging = () => require('../core/NexusMessaging');
|
|
|
12
15
|
const TEMPLATE_BODY_LIMIT = 1024;
|
|
13
16
|
const RECOVERY_MEDIA_URL_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
14
17
|
|
|
18
|
+
const FIXED_TEXT_TEMPLATE_KEY = 'RECOVERY_TEXT_TEMPLATE_SID';
|
|
19
|
+
const FIXED_FILE_TEMPLATE_KEYS = {
|
|
20
|
+
'application/pdf': 'RECOVERY_DOCUMENT_TEMPLATE_SID',
|
|
21
|
+
'image/jpeg': 'RECOVERY_IMAGE_TEMPLATE_SID',
|
|
22
|
+
'image/png': 'RECOVERY_IMAGE_TEMPLATE_SID'
|
|
23
|
+
};
|
|
24
|
+
const FIXED_TEMPLATE_TEXT_LIMIT = 800;
|
|
25
|
+
const FIXED_TEMPLATE_DEFAULT_TEXT = 'Te compartimos un archivo.';
|
|
26
|
+
const FIXED_TEMPLATE_MEDIA_PREFIX_KEY = 'RECOVERY_TEMPLATE_MEDIA_PREFIX';
|
|
27
|
+
|
|
15
28
|
async function resolveRecoveryMediaUrl(media) {
|
|
16
29
|
if (!media?.bucketName || !media?.key) return null;
|
|
17
30
|
try {
|
|
@@ -36,6 +49,75 @@ async function resolveRecoveryBody(messageDoc) {
|
|
|
36
49
|
|
|
37
50
|
const EXPIRED_ERROR_MESSAGE = 'Expired before it could be delivered';
|
|
38
51
|
|
|
52
|
+
// Approved UTILITY, so Meta cannot reclassify it per message; null keeps the one-off template.
|
|
53
|
+
async function fixedRecoveryTemplate(media, body) {
|
|
54
|
+
const text = String(body ?? '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (text.length > FIXED_TEMPLATE_TEXT_LIMIT) return null;
|
|
56
|
+
const config = await getRecoveryTemplateConfig();
|
|
57
|
+
|
|
58
|
+
if (!media?.key) {
|
|
59
|
+
const contentSid = config[FIXED_TEXT_TEMPLATE_KEY];
|
|
60
|
+
return contentSid && text ? { contentSid, variables: { 1: text } } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const contentSid = config[FIXED_FILE_TEMPLATE_KEYS[media.contentType]];
|
|
64
|
+
const mediaPrefix = config[FIXED_TEMPLATE_MEDIA_PREFIX_KEY];
|
|
65
|
+
if (!contentSid || !mediaPrefix) return null;
|
|
66
|
+
const mediaUrl = await resolveRecoveryMediaUrl(media);
|
|
67
|
+
if (!mediaUrl?.startsWith(mediaPrefix)) {
|
|
68
|
+
logger.warn('[TemplateRecovery] Signed URL does not match the fixed template, using a one-off template', { contentSid });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return { contentSid, variables: { 1: text || FIXED_TEMPLATE_DEFAULT_TEXT, 2: mediaUrl.slice(mediaPrefix.length) } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Returns the provider SID if the fixed send got through after all; otherwise the row goes back to queued.
|
|
75
|
+
async function settleFailedFixedSend(messageDoc, contentSid) {
|
|
76
|
+
const accepted = await DeliveryAttempt.findOne(
|
|
77
|
+
{ messageId: messageDoc._id, contentSid, twilioSid: { $ne: null } }, 'twilioSid'
|
|
78
|
+
).lean();
|
|
79
|
+
if (accepted) return accepted.twilioSid;
|
|
80
|
+
await Message.updateOne(
|
|
81
|
+
{ _id: messageDoc._id },
|
|
82
|
+
{ $set: { 'statusInfo.status': 'queued' }, $unset: { 'statusInfo.errorCode': '', 'statusInfo.errorMessage': '' } }
|
|
83
|
+
);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function sendFixedRecovery(messageDoc, template, logCtx, { skipContactGate }) {
|
|
88
|
+
const claim = await Message.updateOne(
|
|
89
|
+
{ _id: messageDoc._id, 'statusInfo.recoverySentAt': null, 'statusInfo.recoveryReleasedAt': null },
|
|
90
|
+
{ $set: { 'statusInfo.recoverySentAt': new Date() } }
|
|
91
|
+
);
|
|
92
|
+
if (!claim.modifiedCount && !claim.nModified) return 'skipped';
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const result = await getMessaging().sendMessage({
|
|
96
|
+
code: messageDoc.numero,
|
|
97
|
+
...contactOf(messageDoc),
|
|
98
|
+
...template,
|
|
99
|
+
parentMessageId: messageDoc._id,
|
|
100
|
+
kind: 'recovery_template',
|
|
101
|
+
body: messageDoc.body,
|
|
102
|
+
triggeredBy: messageDoc.triggeredBy,
|
|
103
|
+
idempotencyKey: `recovery-fixed:${messageDoc._id}`,
|
|
104
|
+
skipContactGate
|
|
105
|
+
});
|
|
106
|
+
if (result?.duplicate || result?.status === 'outbound_blocked') return 'skipped';
|
|
107
|
+
if (!result?.messageId) throw new Error('Provider did not return a messageId for the fixed template send');
|
|
108
|
+
logger.info('[TemplateRecovery] Sent on the fixed template', { ...logCtx, contentSid: template.contentSid });
|
|
109
|
+
return 'sent';
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (await settleFailedFixedSend(messageDoc, template.contentSid)) {
|
|
112
|
+
logger.error('[TemplateRecovery] Fixed template reached the provider but bookkeeping failed', { ...logCtx, error: error.message });
|
|
113
|
+
return 'sent';
|
|
114
|
+
}
|
|
115
|
+
await Message.updateOne({ _id: messageDoc._id }, { $unset: { 'statusInfo.recoverySentAt': '' } });
|
|
116
|
+
logger.warn('[TemplateRecovery] Fixed template send failed, using a one-off template', { ...logCtx, error: error.message });
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
39
121
|
async function closeExpiredRecovery(messageDoc) {
|
|
40
122
|
const claim = await Message.updateOne(
|
|
41
123
|
{ _id: messageDoc._id, 'statusInfo.recoveryReleasedAt': null, 'statusInfo.recoverySentAt': null },
|
|
@@ -110,6 +192,17 @@ async function triggerTemplateRecovery(messageDoc, { source = 'reactive', messag
|
|
|
110
192
|
}
|
|
111
193
|
|
|
112
194
|
const recoveryBody = await resolveRecoveryBody(messageDoc);
|
|
195
|
+
|
|
196
|
+
let fixedSendFailed = false;
|
|
197
|
+
if (messageDoc.media?.key && !hasUnresolvedPhi(recoveryBody)) {
|
|
198
|
+
const fixedTemplate = await fixedRecoveryTemplate(messageDoc.media, recoveryBody);
|
|
199
|
+
// The preemptive caller's own reservation would count against this send; a reactive one has none.
|
|
200
|
+
const outcome = fixedTemplate && await sendFixedRecovery(messageDoc, fixedTemplate, logCtx, { skipContactGate: source === 'preemptive' });
|
|
201
|
+
if (outcome === 'sent') return { delivered: false, deferred: false, sentOnFixedTemplate: true };
|
|
202
|
+
if (outcome) return { delivered: false, deferred: true };
|
|
203
|
+
fixedSendFailed = outcome === null;
|
|
204
|
+
}
|
|
205
|
+
|
|
113
206
|
const templateName = `auto_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
114
207
|
const template = new Template(templateName, 'UTILITY', 'es');
|
|
115
208
|
template.setBody(recoveryBody, []);
|
|
@@ -178,6 +271,13 @@ async function triggerTemplateRecovery(messageDoc, { source = 'reactive', messag
|
|
|
178
271
|
originalMessageSid: messageSid
|
|
179
272
|
});
|
|
180
273
|
|
|
274
|
+
if (fixedSendFailed) {
|
|
275
|
+
safeEmit(getMessaging().getEventBus(), 'message:status', createEvent('message:status', {
|
|
276
|
+
messageId: String(messageDocId),
|
|
277
|
+
to: messageDoc.numero,
|
|
278
|
+
status: 'queued'
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
181
281
|
return { delivered: false, deferred: true };
|
|
182
282
|
} catch (error) {
|
|
183
283
|
logger.error('[TemplateRecovery] Error', { source, messageSid, error: error.message });
|
|
@@ -207,4 +307,4 @@ async function cleanupRecoveryTemplate(messageDoc) {
|
|
|
207
307
|
}
|
|
208
308
|
}
|
|
209
309
|
|
|
210
|
-
module.exports = { handle24HourWindowError, triggerTemplateRecovery, cleanupRecoveryTemplate, resolveRecoveryBody, hasUnresolvedPhi, closeExpiredRecovery };
|
|
310
|
+
module.exports = { settleFailedFixedSend, handle24HourWindowError, triggerTemplateRecovery, cleanupRecoveryTemplate, resolveRecoveryBody, fixedRecoveryTemplate, hasUnresolvedPhi, closeExpiredRecovery };
|
|
@@ -10,7 +10,7 @@ const { DeliveryAttempt } = require('../models/deliveryAttemptModel');
|
|
|
10
10
|
const { alertUndeliverable } = require('../helpers/channelRetryHelper');
|
|
11
11
|
|
|
12
12
|
const { contactOf } = require('../helpers/caregiverHelper');
|
|
13
|
-
const { closeExpiredRecovery } = require('../helpers/templateRecoveryHelper');
|
|
13
|
+
const { closeExpiredRecovery, fixedRecoveryTemplate, hasUnresolvedPhi, resolveRecoveryBody, settleFailedFixedSend } = require('../helpers/templateRecoveryHelper');
|
|
14
14
|
const { getMessages } = require('../services/messageService');
|
|
15
15
|
const { recordUndeliverable } = require('../services/undeliverableService');
|
|
16
16
|
|
|
@@ -104,6 +104,7 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
104
104
|
|
|
105
105
|
let provider;
|
|
106
106
|
let approvalStatus;
|
|
107
|
+
let approvalCategory;
|
|
107
108
|
let checkError = null;
|
|
108
109
|
try {
|
|
109
110
|
provider = this.requireProvider();
|
|
@@ -113,6 +114,7 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
113
114
|
'checkApprovalStatus'
|
|
114
115
|
);
|
|
115
116
|
approvalStatus = status?.approvalRequest?.status?.toUpperCase();
|
|
117
|
+
approvalCategory = status?.approvalRequest?.category?.toUpperCase();
|
|
116
118
|
} catch (err) {
|
|
117
119
|
checkError = err;
|
|
118
120
|
}
|
|
@@ -131,7 +133,7 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
131
133
|
logger.info('[TemplateApprovalJob] Approval status', { templateSid, approvalStatus, attempt, messageId, originalMessageSid });
|
|
132
134
|
|
|
133
135
|
if (approvalStatus === 'APPROVED') {
|
|
134
|
-
return await this._handleApproved({ message, templateSid, originalMessageSid });
|
|
136
|
+
return await this._handleApproved({ message, templateSid, originalMessageSid, category: approvalCategory });
|
|
135
137
|
}
|
|
136
138
|
if (approvalStatus === 'REJECTED') {
|
|
137
139
|
return await this._handleRejected({ provider, message, templateSid });
|
|
@@ -288,7 +290,21 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
288
290
|
return { sent: Boolean(result?.messageId), nudgeMessageId: result?.messageId || null };
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
|
|
293
|
+
// Meta can approve a one-off template as MARKETING, which some patients never receive.
|
|
294
|
+
async _replacementFor(message, category) {
|
|
295
|
+
if (!category || category === 'UTILITY') return null;
|
|
296
|
+
try {
|
|
297
|
+
const body = await resolveRecoveryBody(message);
|
|
298
|
+
return hasUnresolvedPhi(body) ? null : await fixedRecoveryTemplate(message.media, body);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
logger.warn('[TemplateApprovalJob] Could not build the fixed template, sending as approved', {
|
|
301
|
+
messageId: String(message._id), error: err.message
|
|
302
|
+
});
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async _handleApproved({ message, templateSid, originalMessageSid, category }) {
|
|
292
308
|
const claim = await Message.updateOne(
|
|
293
309
|
{ _id: message._id, 'statusInfo.recoverySentAt': null, 'statusInfo.recoveryReleasedAt': null },
|
|
294
310
|
{ $set: { 'statusInfo.recoverySentAt': new Date() } }
|
|
@@ -298,17 +314,27 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
298
314
|
return { success: false, reason: 'already_claimed' };
|
|
299
315
|
}
|
|
300
316
|
|
|
317
|
+
const replacement = await this._replacementFor(message, category);
|
|
318
|
+
|
|
319
|
+
const send = (template) => this.sendMessage({
|
|
320
|
+
code: message.numero,
|
|
321
|
+
...contactOf(message),
|
|
322
|
+
contentSid: template?.contentSid ?? templateSid,
|
|
323
|
+
variables: template?.variables ?? {},
|
|
324
|
+
parentMessageId: message._id,
|
|
325
|
+
kind: 'recovery_template',
|
|
326
|
+
body: message.body,
|
|
327
|
+
idempotencyKey: `recovery:${message._id}`
|
|
328
|
+
});
|
|
329
|
+
|
|
301
330
|
let sendResult;
|
|
302
331
|
try {
|
|
303
|
-
sendResult = await
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
kind: 'recovery_template',
|
|
310
|
-
body: message.body,
|
|
311
|
-
idempotencyKey: `recovery:${message._id}`
|
|
332
|
+
sendResult = await send(replacement).catch(async (err) => {
|
|
333
|
+
if (!replacement) throw err;
|
|
334
|
+
const acceptedSid = await settleFailedFixedSend(message, replacement.contentSid);
|
|
335
|
+
if (acceptedSid) return { messageId: acceptedSid };
|
|
336
|
+
logger.warn('[TemplateApprovalJob] Fixed template failed, sending as approved', { messageId: String(message._id), error: err.message });
|
|
337
|
+
return send(null);
|
|
312
338
|
});
|
|
313
339
|
if (sendResult?.duplicate) {
|
|
314
340
|
logger.info('[TemplateApprovalJob] Recovery send already claimed elsewhere', { templateSid, messageId: message._id });
|
|
@@ -335,7 +361,7 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
335
361
|
|
|
336
362
|
await DeliveryAttempt.updateOne(
|
|
337
363
|
{ messageId: message._id, kind: 'recovery_template_setup', twilioSid: templateSid },
|
|
338
|
-
{ $set: { status: 'sent', completedAt: new Date() } }
|
|
364
|
+
{ $set: { status: 'sent', ...(category && { templateCategory: category }), completedAt: new Date() } }
|
|
339
365
|
);
|
|
340
366
|
|
|
341
367
|
return { success: true, recoveryMessageId: sendResult.messageId };
|
|
@@ -26,6 +26,7 @@ const deliveryAttemptSchema = new mongoose.Schema({
|
|
|
26
26
|
enum: STATUS_VALUES,
|
|
27
27
|
default: null
|
|
28
28
|
},
|
|
29
|
+
templateCategory: { type: String, default: null },
|
|
29
30
|
errorCode: { type: String, default: null },
|
|
30
31
|
errorMessage: { type: String, default: null },
|
|
31
32
|
errorSource: {
|
|
@@ -140,7 +140,7 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
|
|
|
140
140
|
// The cause stays internal: callers tell a drain that refused the turn from one that may have answered.
|
|
141
141
|
throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount, error);
|
|
142
142
|
}
|
|
143
|
-
//
|
|
143
|
+
// A send that threw may still have reached the patient.
|
|
144
144
|
return { delivery: turn?.delivery ?? null, sendThrew: settled === 'threw', reply: turn?.output ?? null };
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -731,7 +731,7 @@ async function isEscalationApprover(operator) {
|
|
|
731
731
|
}
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
-
//
|
|
734
|
+
// Unpicked, the doctor is the one recording if they approve, or the only approver there is.
|
|
735
735
|
async function prescriberOf(email, operator) {
|
|
736
736
|
if (email != null && typeof email !== 'string') throw httpError(400, 'prescriberEmail must be text');
|
|
737
737
|
let approvers;
|
|
@@ -748,7 +748,7 @@ async function prescriberOf(email, operator) {
|
|
|
748
748
|
return { name: chosen.name, email: chosen.email };
|
|
749
749
|
}
|
|
750
750
|
|
|
751
|
-
//
|
|
751
|
+
// Null when the list could not be read; the review is still returned.
|
|
752
752
|
async function prescribersForRead() {
|
|
753
753
|
try {
|
|
754
754
|
return (await listEscalationApprovers()).map(({ name, email }) => ({ name, email }));
|
|
@@ -788,7 +788,6 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
|
788
788
|
};
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
-
// The instructions a plan would give the assistant, for the doctor to read before approving.
|
|
792
791
|
function previewPlanInstructions({ id, plan } = {}) {
|
|
793
792
|
reviewIdOf(id);
|
|
794
793
|
return planInstructions(planOf(plan));
|
|
@@ -1868,7 +1867,7 @@ async function recordDoctorDecision({
|
|
|
1868
1867
|
if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
|
|
1869
1868
|
plan = planOf(requestedPlan);
|
|
1870
1869
|
plan = { ...plan, ...approvedInstructions(plan, instructions) };
|
|
1871
|
-
// Anyone may record
|
|
1870
|
+
// Anyone may record a plan, often beside the doctor; one with a drug, study, note or edit names the doctor who gave it.
|
|
1872
1871
|
const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
|
|
1873
1872
|
if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) {
|
|
1874
1873
|
plan = { ...plan, prescriber: await prescriberOf(prescriberEmail, operator) };
|
|
@@ -2145,7 +2144,6 @@ async function issuePrescription(review, plan, prescriberEmail) {
|
|
|
2145
2144
|
}
|
|
2146
2145
|
}
|
|
2147
2146
|
|
|
2148
|
-
// How the assistant's reply left: sent, queued for the patient's reply window, not sent, or unknown.
|
|
2149
2147
|
function replyOutcome(delivery, sendThrew) {
|
|
2150
2148
|
if (sendThrew) return 'unknown';
|
|
2151
2149
|
if (!delivery) return 'not_sent';
|
|
@@ -2155,7 +2153,7 @@ function replyOutcome(delivery, sendThrew) {
|
|
|
2155
2153
|
return 'unknown';
|
|
2156
2154
|
}
|
|
2157
2155
|
|
|
2158
|
-
// A
|
|
2156
|
+
// A notice that fails is logged and never undoes the send.
|
|
2159
2157
|
async function notifyPlanIssues(review, issues) {
|
|
2160
2158
|
try {
|
|
2161
2159
|
const { patient, doctor, link } = await teamAlertIdentity(review);
|
|
@@ -2172,7 +2170,6 @@ async function notifyPlanIssues(review, issues) {
|
|
|
2172
2170
|
}
|
|
2173
2171
|
}
|
|
2174
2172
|
|
|
2175
|
-
// The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen or the reply needs a look.
|
|
2176
2173
|
async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
|
|
2177
2174
|
const reviewId = claimed._id;
|
|
2178
2175
|
const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
|
|
@@ -53,6 +53,7 @@ async function releaseOne(message, now) {
|
|
|
53
53
|
return false;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const { media } = message;
|
|
56
57
|
const body = await resolveRecoveryBody(message);
|
|
57
58
|
if (hasUnresolvedPhi(body)) {
|
|
58
59
|
logger.error('[HeldMessage] Body still carries a PHI placeholder, refusing to release', logCtx);
|
|
@@ -72,6 +73,7 @@ async function releaseOne(message, now) {
|
|
|
72
73
|
code: message.numero,
|
|
73
74
|
...contactOf(message),
|
|
74
75
|
body,
|
|
76
|
+
...(media?.key && { media, fileUrl: `https://${media.bucketName}.s3.amazonaws.com/${media.key}`, fileType: media.mediaType }),
|
|
75
77
|
parentMessageId: message._id,
|
|
76
78
|
kind: 'freeform',
|
|
77
79
|
idempotencyKey: `window-release:${message._id}`
|