@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.
@@ -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
- // A route is not a dose: the doctor still says how many tablets.
5
- const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
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(/^(.*?)\s+(cada [^,;]+?)(?:\s+por\s+(\d+)\s+d[ií]as?)?(?:\s*[,;]\s*(.*))?$/i);
59
+ const match = text.match(DOSING);
60
60
  if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
61
- const [, dose, frequency, days, rest] = match;
62
- // A duration said after other directions («…, y tragar, por 7 días») is still the days.
63
- const lateDays = !days && rest?.match(/(?:^|[,;]\s*|\s)por\s+(\d+)\s+d[ií]as?\s*$/i);
64
- return {
65
- dose: ROUTE_ONLY.test(dose) ? null : dose,
66
- frequency,
67
- days: Number(days ?? lateDays?.[1]) || null,
68
- rest: (lateDays ? rest.slice(0, lateDays.index).replace(/[,;\s]+$/, '') : rest) || null,
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(oneLineOf(item.drug).toLowerCase()) && seen.add(oneLineOf(item.drug).toLowerCase()))
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 plan's own words, so what the doctor wrote anywhere in it is never flagged.
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 plain = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
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
- const text = plain(reply);
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 unplannedDrugs = unplannedDrugsIn(reply, planned);
114
- const issues = [];
115
- for (const item of plan.items) {
116
- const name = plain(item.drug).split(/[\s/]/)[0];
117
- if (name && !text.includes(name)) issues.push(`no menciona ${item.drug}`);
118
- }
119
- const strays = (pattern) => [...numbersIn(reply, pattern)].filter((value) => !numbersIn(planned, pattern).has(value));
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 FORMULARY_DRUGS = TREATMENT_FORMULARY.flatMap((entry) => entry.drugs).filter((drug) => drug.isPharmacological !== false);
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
- // Drugs the text names that the planned text does not; a brand of a planned drug counts as planned, a combination only when named whole.
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 its reservation must not also count.
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
- async _handleApproved({ message, templateSid, originalMessageSid }) {
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 this.sendMessage({
304
- code: message.numero,
305
- ...contactOf(message),
306
- contentSid: templateSid,
307
- variables: {},
308
- parentMessageId: message._id,
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
- // What the reply's send returned, or null when the turn produced or sent nothing; a send that threw may still have reached the patient.
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
- // The doctor whose word the plan is, from the approving doctors; unpicked, the one recording it if they approve, or the only one there is.
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
- // Who can sign a receta, for whoever records the plan to pick; null when the list could not be read, which the read survives.
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 it, often beside the doctor; a drug, a study, a note, an edit or a named drug names the doctor who gave it.
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 reply in the case's alarm thread; a notice that fails is logged and never undoes the send.
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}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18778",
3
+ "version": "6.4.0-dev.18830",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",