@peopl-health/nexus 6.4.0-dev.18778 → 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 +18 -20
- package/lib/clinical/helpers/planTextHelper.js +98 -0
- package/lib/clinical/services/doctorPlanService.js +14 -28
- package/lib/clinical/services/treatmentProposalService.js +17 -2
- 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
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
const { oneLineOf } = require('../../utils/coercionUtils');
|
|
2
|
+
const { normalizeForAnchor: drugKey } = require('../../utils/textMatchUtils');
|
|
3
|
+
const { DURATION_SOURCE, durationDays, isRouteOnly } = require('./planTextHelper');
|
|
2
4
|
|
|
3
5
|
const DEFAULT_FOLLOW_UP_HOURS = Object.freeze({ check_in: 24, vigilancia: 48 });
|
|
4
|
-
//
|
|
5
|
-
const
|
|
6
|
+
// «1 tableta» «cada 8 horas | c/8h» «por 5 días», then «, …» what else the formulary says.
|
|
7
|
+
const DOSING = new RegExp(`^(.*?)\\s+((?:cada|c\\/)\\s*[^,;]+?)(?:\\s+(${DURATION_SOURCE}))?(?:\\s*[,;]\\s*(.*))?$`, 'i');
|
|
8
|
+
// Only a clause of its own: «…, y tragar, por 7 días» gives the days, «…, no manejar durante 2 días» does not.
|
|
9
|
+
const LATE_DURATION = new RegExp(`(?:^|[,;]\\s*)(${DURATION_SOURCE})\\s*$`, 'i');
|
|
6
10
|
|
|
7
11
|
const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
|
|
8
12
|
|
|
@@ -16,7 +20,6 @@ const FOLLOW_UP_LINES = {
|
|
|
16
20
|
vigilancia: (hours) => `Seguimiento: queda en vigilancia las próximas ${hours} horas; le escribiremos para ver cómo va.`,
|
|
17
21
|
};
|
|
18
22
|
|
|
19
|
-
// What the assistant is told to pass on; it writes the patient message itself.
|
|
20
23
|
function planInstructions(plan) {
|
|
21
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.'];
|
|
22
25
|
if (plan.items.length) {
|
|
@@ -34,11 +37,10 @@ function planInstructions(plan) {
|
|
|
34
37
|
|
|
35
38
|
// An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
|
|
36
39
|
function assistantInstructionsOf(plan) {
|
|
37
|
-
if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
|
|
40
|
+
if (!needsPrescriber(plan) || oneLineOf(plan.instructions).includes(PRESCRIPTION_LINE)) return plan.instructions;
|
|
38
41
|
return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
// What the team's case shows as the indication, in the doctor's terms rather than the assistant's.
|
|
42
44
|
function planSummary(plan) {
|
|
43
45
|
const lines = [...plan.items.map(itemLine), ...plan.studies.map((study) => `Estudio: ${studyLine(study)}`)];
|
|
44
46
|
if (plan.homeCare.length) lines.push(`Cuidados en casa: ${plan.homeCare.join('; ')}.`);
|
|
@@ -46,30 +48,26 @@ function planSummary(plan) {
|
|
|
46
48
|
return lines.join('\n');
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
// The receta the Airtable automations turn into the doctor's PDF.
|
|
50
51
|
function prescriptionText(plan) {
|
|
51
52
|
const lines = plan.items.map(itemLine);
|
|
52
53
|
if (plan.studies.length) lines.push('Estudios:', ...plan.studies.map(studyLine));
|
|
53
54
|
return lines.join('\n');
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// «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.
|
|
57
57
|
function dosingOf(instructions) {
|
|
58
58
|
const text = oneLineOf(instructions);
|
|
59
|
-
const match = text.match(
|
|
59
|
+
const match = text.match(DOSING);
|
|
60
60
|
if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
|
|
61
|
-
const [, dose, frequency,
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
61
|
+
const [, dose, frequency, duration, , , , rest] = match;
|
|
62
|
+
const late = !duration ? rest?.match(LATE_DURATION) : null;
|
|
63
|
+
const said = duration ?? late?.[1];
|
|
64
|
+
const days = durationDays(said);
|
|
65
|
+
const others = late ? rest.slice(0, late.index).replace(/[,;\s]+$/, '') : rest;
|
|
66
|
+
// A range («por 3 a 5 días») is the doctor's to settle, so it stays in the note.
|
|
67
|
+
const left = [others, said && !days ? said : null].filter(Boolean).join(', ');
|
|
68
|
+
return { dose: isRouteOnly(dose) ? null : dose, frequency, days, rest: left || null };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
// The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
|
|
73
71
|
// What the formulary adds after the dosing («además, valoración por oftalmología») goes to the note.
|
|
74
72
|
function proposedPlanOf(draft) {
|
|
75
73
|
const seen = new Set();
|
|
@@ -77,7 +75,7 @@ function proposedPlanOf(draft) {
|
|
|
77
75
|
const items = (draft?.symptoms ?? [])
|
|
78
76
|
.filter((entry) => entry?.proposal?.confirmed)
|
|
79
77
|
.flatMap((entry) => (entry.proposal.items ?? []).map((item) => ({ item, formularyKey: entry.proposal.formularyKey })))
|
|
80
|
-
.filter(({ item }) => !seen.has(
|
|
78
|
+
.filter(({ item }) => !seen.has(drugKey(item.drug)) && seen.add(drugKey(item.drug)))
|
|
81
79
|
.map(({ item, formularyKey }) => {
|
|
82
80
|
const { rest, ...dosing } = dosingOf(item.instructions);
|
|
83
81
|
if (rest) rests.push(`${item.drug}: ${rest}`);
|
|
@@ -86,7 +84,7 @@ function proposedPlanOf(draft) {
|
|
|
86
84
|
return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
|
|
87
85
|
}
|
|
88
86
|
|
|
89
|
-
// The
|
|
87
|
+
// The note and home care count, so what the doctor wrote anywhere in the plan is never flagged.
|
|
90
88
|
const plannedTextOf = (plan) => [
|
|
91
89
|
...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
|
|
92
90
|
...plan.studies.flatMap((study) => [study.name, study.instructions]),
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const { foldAccents } = require('../../utils/formatUtils');
|
|
2
|
+
const { normalizeForAnchor } = require('../../utils/textMatchUtils');
|
|
3
|
+
|
|
4
|
+
const NUMBER_WORDS = Object.freeze({
|
|
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,
|
|
6
|
+
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15, dieciseis: 16, dieciocho: 18, veinte: 20, veinticuatro: 24,
|
|
7
|
+
treinta: 30, 'treinta y seis': 36, cuarenta: 40, 'cuarenta y ocho': 48, 'setenta y dos': 72,
|
|
8
|
+
});
|
|
9
|
+
const WORDS = Object.keys(NUMBER_WORDS).sort((a, b) => b.length - a.length).join('|');
|
|
10
|
+
const COUNT = `(?:\\d+|${WORDS})`;
|
|
11
|
+
// «y» is left out: «treinta y cinco» is one number, not a range.
|
|
12
|
+
const TO = '\\s*(?:a|-|o|u)\\s*';
|
|
13
|
+
const DAYS_PER = Object.freeze({ d: 1, dia: 1, dias: 1, semana: 7, semanas: 7, mes: 30, meses: 30 });
|
|
14
|
+
const DAY_UNITS = 'd[ií]as?|semanas?|mes(?:es)?|d\\b';
|
|
15
|
+
|
|
16
|
+
// «por 5 días», «durante una semana», «x 7 días», «por unos 5 días», «por 3 a 5 días»
|
|
17
|
+
const DURATION_SOURCE = `(?:por|durante|x)\\s+(?:(?:unos|unas|los|las|pr[oó]ximos|otros)\\s+)*(${COUNT})(?:${TO}(${COUNT}))?\\s*(${DAY_UNITS})`;
|
|
18
|
+
const DURATION = new RegExp(`^${DURATION_SOURCE}$`, 'i');
|
|
19
|
+
|
|
20
|
+
const ROUTES = new Set(['vo', 'po', 'oral', 'iv', 'ev', 'intravenosa', 'im', 'intramuscular', 'sc', 'subcutanea', 'sl', 'sublingual', 'topica', 'rectal']);
|
|
21
|
+
// What a drug's name may carry that does not name it: «Ondansetrón 8 mg», «Metoclopramida gotas».
|
|
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)$/;
|
|
23
|
+
|
|
24
|
+
const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
|
|
25
|
+
// Only filler may sit between: «no necesitas ir a urgencias» calms, «no es normal, ve a urgencias» does not.
|
|
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*$/;
|
|
27
|
+
|
|
28
|
+
const folded = (text) => foldAccents(text).toLowerCase();
|
|
29
|
+
|
|
30
|
+
const countOf = (word) => (/^\d+$/.test(word) ? Number(word) : NUMBER_WORDS[folded(word).replace(/\s+/g, ' ')] ?? null);
|
|
31
|
+
const daysPer = (unit) => DAYS_PER[folded(unit)];
|
|
32
|
+
|
|
33
|
+
const isRouteOnly = (text) => ROUTES.has(folded(text).replace(/\./g, '').replace(/^(?:por\s+)?(?:via\s+)?/, '').trim());
|
|
34
|
+
|
|
35
|
+
const drugNameOf = (text) => normalizeForAnchor(text).split(' ').filter((word) => word && !NOT_A_NAME.test(word)).join(' ');
|
|
36
|
+
|
|
37
|
+
// Null for a range or an unknown count: the doctor settles those.
|
|
38
|
+
function durationDays(phrase) {
|
|
39
|
+
const match = String(phrase ?? '').trim().match(DURATION);
|
|
40
|
+
if (!match || match[2]) return null;
|
|
41
|
+
const count = countOf(match[1]);
|
|
42
|
+
return count ? count * daysPer(match[3]) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NUMBER_WORD = new RegExp(`\\b(${WORDS})\\b`, 'g');
|
|
46
|
+
const IN_DAYS = new RegExp(`\\b(\\d+)(?:${TO}(\\d+))?\\s*(semanas?|mes(?:es)?|d)\\b`, 'g');
|
|
47
|
+
const DAYS_IN_UNIT = (_, from, to, unit) => [from, to].filter(Boolean).map((value) => value * daysPer(unit)).join(' a ').concat(' dias');
|
|
48
|
+
const TIMES_A_DAY = (written, ...counts) => {
|
|
49
|
+
const times = counts.slice(0, 2).filter(Boolean).map(Number);
|
|
50
|
+
return times.every((count) => count > 0 && 24 % count === 0) ? times.map((count) => `cada ${24 / count} horas`).join(' o ') : written;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// A dot stays decimal, as written in Mexico.
|
|
54
|
+
function dosingTextOf(text) {
|
|
55
|
+
return folded(text)
|
|
56
|
+
.replace(/\b[1-9]\d{0,2}(?:,\d{3})+\b/g, (thousands) => thousands.replace(/,/g, ''))
|
|
57
|
+
.replace(/(\d),(\d)/g, '$1.$2')
|
|
58
|
+
.replace(NUMBER_WORD, (word) => String(countOf(word)))
|
|
59
|
+
.replace(/\bc\/\s*(\d+)\s*(?:h|hs|hrs?|horas?)\b/g, 'cada $1 horas')
|
|
60
|
+
.replace(new RegExp(`\\b(\\d+)(?:${TO}(\\d+))?\\s+ve(?:z|ces)\\s+(?:al|por|cada)\\s+dia\\b`, 'g'), TIMES_A_DAY)
|
|
61
|
+
.replace(/\bcada\s+hora\b/g, 'cada 1 horas')
|
|
62
|
+
.replace(/\bcada\s+dia\b/g, 'cada 24 horas')
|
|
63
|
+
.replace(/\b(?:por|durante|x)\s+(?:(?:unos|unas|los|las|proximos|otros)\s+)*(?=\d)/g, 'por ')
|
|
64
|
+
.replace(IN_DAYS, DAYS_IN_UNIT)
|
|
65
|
+
.replace(/\s+/g, ' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const RANGE = `(\\d+)(?:${TO}(\\d+))?`;
|
|
69
|
+
const HOURS = new RegExp(`cada\\s+${RANGE}\\s*h(?:s|rs?|oras?)?\\b`, 'g');
|
|
70
|
+
const DAYS = new RegExp(`por\\s+${RANGE}\\s+dias?\\b`, 'g');
|
|
71
|
+
const AMOUNTS = /\b(\d+(?:\.\d+)?)\s*(microgramos?|miligramos?|gramos?|mcg|µg|ug|mg|gr?|ml|ui)(?![a-z])/g;
|
|
72
|
+
const UNIT_SCALE = Object.freeze({
|
|
73
|
+
microgramo: ['mg', 0.001], microgramos: ['mg', 0.001], mcg: ['mg', 0.001], µg: ['mg', 0.001], ug: ['mg', 0.001],
|
|
74
|
+
miligramo: ['mg', 1], miligramos: ['mg', 1], mg: ['mg', 1],
|
|
75
|
+
gramo: ['mg', 1000], gramos: ['mg', 1000], g: ['mg', 1000], gr: ['mg', 1000],
|
|
76
|
+
ml: ['ml', 1], ui: ['ui', 1],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const valuesIn = (text, pattern) => new Set([...text.matchAll(pattern)].flatMap((match) => [match[1], match[2]]).filter(Boolean).map(Number));
|
|
80
|
+
|
|
81
|
+
// Amounts are keyed in mg, ml or UI, so «1 g» and «1000 mg» meet.
|
|
82
|
+
function dosingFactsOf(text) {
|
|
83
|
+
const dosing = dosingTextOf(text);
|
|
84
|
+
const amounts = new Map([...dosing.matchAll(AMOUNTS)].map(([written, value, unit]) => {
|
|
85
|
+
const [base, scale] = UNIT_SCALE[unit];
|
|
86
|
+
return [`${Math.round(Number(value) * scale * 1000) / 1000} ${base}`, written];
|
|
87
|
+
}));
|
|
88
|
+
return { hours: valuesIn(dosing, HOURS), days: valuesIn(dosing, DAYS), amounts };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function urgencyWordsIn(text) {
|
|
92
|
+
const plain = folded(text);
|
|
93
|
+
return new Set([...plain.matchAll(URGENCY_WORDS)]
|
|
94
|
+
.filter((match) => !CALMING_BEFORE.test(plain.slice(0, match.index)))
|
|
95
|
+
.map((match) => match[1]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { DURATION_SOURCE, dosingFactsOf, drugNameOf, durationDays, isRouteOnly, urgencyWordsIn };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const { DEFAULT_FOLLOW_UP_HOURS, planInstructions, plannedTextOf } = require('../helpers/doctorPlanHelper');
|
|
2
|
+
const { dosingFactsOf, urgencyWordsIn } = require('../helpers/planTextHelper');
|
|
2
3
|
const { httpError } = require('../../utils/httpErrorUtils');
|
|
3
4
|
const { oneLineOf } = require('../../utils/coercionUtils');
|
|
4
5
|
const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
|
|
5
|
-
const { unplannedDrugsIn } = require('./treatmentProposalService');
|
|
6
|
+
const { isDrugNamed, unplannedDrugsIn } = require('./treatmentProposalService');
|
|
6
7
|
|
|
7
8
|
const FIRST_LINE = '1L';
|
|
8
9
|
const MAX_ENTRIES = 10;
|
|
@@ -43,9 +44,8 @@ const listOf = (value, name) => {
|
|
|
43
44
|
|
|
44
45
|
function itemOf(item, index) {
|
|
45
46
|
const name = `plan.items[${index}]`;
|
|
46
|
-
const position = textOf(item?.position, `${name}.position`);
|
|
47
|
+
const position = textOf(item?.position, `${name}.position`)?.toUpperCase() ?? null;
|
|
47
48
|
const reason = textOf(item?.reason, `${name}.reason`);
|
|
48
|
-
// An alternative to the first line has to say why the first line does not fit.
|
|
49
49
|
if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
|
|
50
50
|
return {
|
|
51
51
|
drug: neededText(item?.drug, `${name}.drug`),
|
|
@@ -67,7 +67,6 @@ function followUpOf(value) {
|
|
|
67
67
|
return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// The plan as the doctor approved it, refused whole when any line is incomplete.
|
|
71
70
|
function planOf(input) {
|
|
72
71
|
if (!isPlainObject(input)) throw httpError(400, 'plan is required');
|
|
73
72
|
const plan = {
|
|
@@ -89,7 +88,6 @@ function planOf(input) {
|
|
|
89
88
|
return plan;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
// The doctor's edit wins; with none, the instructions follow the plan.
|
|
93
91
|
function approvedInstructions(plan, edited) {
|
|
94
92
|
if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
|
|
95
93
|
if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
|
|
@@ -98,33 +96,21 @@ function approvedInstructions(plan, edited) {
|
|
|
98
96
|
return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
|
|
99
97
|
}
|
|
100
98
|
|
|
101
|
-
const
|
|
102
|
-
const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
|
|
103
|
-
const numbersIn = (text, pattern) => new Set([...plain(text).matchAll(pattern)].map((match) => match[1].replace(',', '.')));
|
|
104
|
-
const HOURS = /cada\s+(\d+)\s*h(?:rs?|oras?)?\b/g;
|
|
105
|
-
const DAYS = /por\s+(\d+)\s+dias?/g;
|
|
106
|
-
const MG = /(\d+(?:[.,]\d+)?)\s*mg\b/g;
|
|
99
|
+
const strays = (said, planned) => [...said].filter((value) => !planned.has(value));
|
|
107
100
|
|
|
108
|
-
// What in the assistant's reply strays from the plan, for the team to check.
|
|
109
101
|
function replyIssues(plan, reply) {
|
|
110
|
-
|
|
111
|
-
if (!text) return ['no se pudo leer el mensaje enviado'];
|
|
102
|
+
if (!oneLineOf(reply)) return ['no se pudo leer el mensaje enviado'];
|
|
112
103
|
const planned = plannedTextOf(plan);
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
for (const value of strays(HOURS)) issues.push(`dice «cada ${value} horas», que no está en el plan`);
|
|
121
|
-
for (const value of strays(DAYS)) issues.push(`dice «por ${value} días», que no está en el plan`);
|
|
122
|
-
for (const value of strays(MG)) issues.push(`dice «${value} mg», que no está en el plan`);
|
|
123
|
-
for (const name of unplannedDrugs) issues.push(`menciona ${name}, que el médico no indicó`);
|
|
124
|
-
const plannedWords = new Set([...plain(planned).matchAll(URGENCY_WORDS)].map((match) => match[1]));
|
|
125
|
-
for (const word of new Set([...text.matchAll(URGENCY_WORDS)].map((match) => match[1]))) {
|
|
126
|
-
if (!plannedWords.has(word)) issues.push(`usa «${word}», que el médico no indicó`);
|
|
104
|
+
const said = dosingFactsOf(reply);
|
|
105
|
+
const allowed = dosingFactsOf(planned);
|
|
106
|
+
const issues = plan.items.filter((item) => !isDrugNamed(reply, item.drug)).map((item) => `no menciona ${item.drug}`);
|
|
107
|
+
for (const hours of strays(said.hours, allowed.hours)) issues.push(`dice «cada ${hours} horas», que no está en el plan`);
|
|
108
|
+
for (const days of strays(said.days, allowed.days)) issues.push(`dice «por ${days} días», que no está en el plan`);
|
|
109
|
+
for (const [amount, written] of said.amounts) {
|
|
110
|
+
if (!allowed.amounts.has(amount)) issues.push(`dice «${written}», que no está en el plan`);
|
|
127
111
|
}
|
|
112
|
+
for (const name of unplannedDrugsIn(reply, planned)) issues.push(`menciona ${name}, que el médico no indicó`);
|
|
113
|
+
for (const word of strays(urgencyWordsIn(reply), urgencyWordsIn(planned))) issues.push(`usa «${word}», que el médico no indicó`);
|
|
128
114
|
return issues;
|
|
129
115
|
}
|
|
130
116
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { drugNameOf } = require('../helpers/planTextHelper');
|
|
1
2
|
const { TREATMENT_FORMULARY, findDrug, formularyFor } = require('../protocols/treatmentFormulary');
|
|
2
3
|
|
|
3
4
|
const { normalizeForAnchor: key } = require('../../utils/textMatchUtils');
|
|
@@ -90,7 +91,8 @@ function validateProposal(proposal, context = {}) {
|
|
|
90
91
|
|
|
91
92
|
const namesOf = (drug) => [drug.drug, ...drug.drug.split('/'), ...String(drug.brand ?? '').split('/')].map(key).filter((name) => name.length >= 4);
|
|
92
93
|
|
|
93
|
-
const
|
|
94
|
+
const ALL_FORMULARY_DRUGS = TREATMENT_FORMULARY.flatMap((entry) => entry.drugs);
|
|
95
|
+
const FORMULARY_DRUGS = ALL_FORMULARY_DRUGS.filter((drug) => drug.isPharmacological !== false);
|
|
94
96
|
const DRUG_NAMES = [...new Set(FORMULARY_DRUGS.flatMap(namesOf))];
|
|
95
97
|
|
|
96
98
|
function resolvedDrugs(proposals) {
|
|
@@ -105,7 +107,7 @@ function drugsNamedIn(text) {
|
|
|
105
107
|
return DRUG_NAMES.filter((name) => padded.includes(` ${name} `));
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
//
|
|
110
|
+
// A brand of a planned drug counts as planned; a combination only when named whole.
|
|
109
111
|
function unplannedDrugsIn(text, plannedText) {
|
|
110
112
|
const planned = new Set(drugsNamedIn(plannedText));
|
|
111
113
|
const ownNames = (drug) => [drug.drug, ...String(drug.brand ?? '').split('/')].map(key).filter(Boolean);
|
|
@@ -134,8 +136,21 @@ function crossSymptomIssues(proposals) {
|
|
|
134
136
|
return issues;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
// A combination needs every part; the dose or form beside a name («Ondansetrón 8 mg») need not be said.
|
|
140
|
+
function isDrugNamed(text, drugName) {
|
|
141
|
+
const padded = ` ${key(text)} `;
|
|
142
|
+
const says = (name) => Boolean(name) && padded.includes(` ${name} `);
|
|
143
|
+
const name = String(drugName ?? '');
|
|
144
|
+
const outside = name.replace(/\(.*?\)/g, ' ');
|
|
145
|
+
const inside = [...name.matchAll(/\((.*?)\)/g)].map((match) => drugNameOf(match[1]));
|
|
146
|
+
const brands = ALL_FORMULARY_DRUGS.filter((drug) => drugNameOf(drug.drug) === drugNameOf(outside)).flatMap((drug) => String(drug.brand ?? '').split('/').map(key));
|
|
147
|
+
const parts = outside.split(/\s*[/+]\s*/).map(drugNameOf).filter(Boolean);
|
|
148
|
+
return (parts.length > 0 && parts.every(says)) || [...inside, ...brands].some(says);
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
module.exports = {
|
|
138
152
|
JUSTIFICATION_KINDS,
|
|
153
|
+
isDrugNamed,
|
|
139
154
|
validateProposal,
|
|
140
155
|
unproposedDrugsIn,
|
|
141
156
|
unplannedDrugsIn,
|
|
@@ -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}`
|