@peopl-health/nexus 6.4.0-dev.18675 → 6.4.0-dev.18775
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/adapters/TwilioProvider.js +16 -4
- package/lib/clinical/helpers/doctorPlanHelper.js +30 -2
- package/lib/clinical/providers/BaseLLMProvider.js +7 -6
- package/lib/clinical/tools/getPatientRiskAssessmentTool.js +1 -1
- package/lib/clinical/tools/setContingencyPlanTool.js +1 -1
- package/lib/controllers/escalationReviewController.js +4 -2
- package/lib/controllers/mediaController.js +2 -8
- package/lib/controllers/prescriptionController.js +4 -28
- package/lib/core/NexusMessaging.js +8 -5
- package/lib/helpers/mediaHelper.js +12 -2
- package/lib/index.d.ts +1 -1
- package/lib/jobs/ScheduledMessageJob.js +1 -7
- package/lib/models/escalationReviewModel.js +6 -1
- package/lib/services/assistantMessageService.js +12 -6
- package/lib/services/escalationReviewService.js +127 -12
- package/lib/services/officeAssistantService.js +3 -9
- package/lib/services/prescriptionService.js +30 -0
- package/lib/utils/mediaValidator.js +8 -0
- package/lib/utils/s3Utils.js +32 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
const { isBenchMode } = require('../utils/benchModeHelper');
|
|
11
11
|
|
|
12
12
|
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
13
|
+
const { templateMediaOf } = require('../helpers/mediaHelper');
|
|
13
14
|
const { recordDeliveryAttempt } = require('../helpers/deliveryAttemptHelper');
|
|
14
15
|
const { blockedSendResult, isOutboundAllowed, lastDigits } = require('../helpers/outboundAllowlistHelper');
|
|
15
16
|
|
|
@@ -301,6 +302,10 @@ class TwilioProvider extends MessageProvider {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
async renderTemplate(contentSid, variables) {
|
|
305
|
+
return (await this.renderTemplateContent(contentSid, variables))?.body ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async renderTemplateContent(contentSid, variables) {
|
|
304
309
|
try {
|
|
305
310
|
if (!contentSid) return null;
|
|
306
311
|
|
|
@@ -311,18 +316,21 @@ class TwilioProvider extends MessageProvider {
|
|
|
311
316
|
return null;
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
const [mediaUrl] = template.types['twilio/media']?.media || template.types['twilio/card']?.media || [];
|
|
320
|
+
const media = mediaUrl ? templateMediaOf(this._renderTemplateWithVariables(mediaUrl, variables)) : null;
|
|
321
|
+
|
|
314
322
|
let textContent = this._extractTextFromTemplate(template);
|
|
315
323
|
|
|
316
324
|
if (!textContent) {
|
|
317
325
|
logger.warn('[TwilioProvider] No text content found in template:', contentSid);
|
|
318
|
-
return null;
|
|
326
|
+
return { body: null, media };
|
|
319
327
|
}
|
|
320
328
|
|
|
321
329
|
if (variables && Object.keys(variables).length > 0) {
|
|
322
|
-
return this._renderTemplateWithVariables(textContent, variables);
|
|
330
|
+
return { body: this._renderTemplateWithVariables(textContent, variables), media };
|
|
323
331
|
}
|
|
324
332
|
|
|
325
|
-
return textContent.trim();
|
|
333
|
+
return { body: textContent.trim(), media };
|
|
326
334
|
} catch (error) {
|
|
327
335
|
logger.error('[TwilioProvider] Error rendering template:', error.message);
|
|
328
336
|
return null;
|
|
@@ -356,7 +364,11 @@ class TwilioProvider extends MessageProvider {
|
|
|
356
364
|
}
|
|
357
365
|
|
|
358
366
|
if (types['twilio/flows']) return types['twilio/flows'].body || '';
|
|
359
|
-
if (types['twilio/media']) return types['twilio/media'].
|
|
367
|
+
if (types['twilio/media']) return types['twilio/media'].body || '';
|
|
368
|
+
if (types['twilio/card']) {
|
|
369
|
+
const card = types['twilio/card'];
|
|
370
|
+
return [card.title, card.body, card.subtitle].filter(Boolean).join('\n\n');
|
|
371
|
+
}
|
|
360
372
|
|
|
361
373
|
const fallback = Object.values(types).find(t => t?.body);
|
|
362
374
|
return fallback?.body || '';
|
|
@@ -93,6 +93,9 @@ function planOf(input) {
|
|
|
93
93
|
const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
|
|
94
94
|
|
|
95
95
|
const daysText = (days) => (days === 1 ? '1 día' : `${days} días`);
|
|
96
|
+
const itemLine = (item) => `${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`;
|
|
97
|
+
const studyLine = (study) => `${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`;
|
|
98
|
+
const PRESCRIPTION_LINE = 'Justo después de tu mensaje le llega aparte la receta del médico en PDF; dile que la va a recibir.';
|
|
96
99
|
|
|
97
100
|
const FOLLOW_UP_LINES = {
|
|
98
101
|
check_in: (hours) => `Seguimiento: le escribiremos en unas ${hours} horas para ver cómo va.`,
|
|
@@ -103,14 +106,36 @@ const FOLLOW_UP_LINES = {
|
|
|
103
106
|
function planInstructions(plan) {
|
|
104
107
|
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.'];
|
|
105
108
|
if (plan.items.length) {
|
|
106
|
-
lines.push('Medicamentos:', ...plan.items.map((item) => `- ${
|
|
109
|
+
lines.push('Medicamentos:', ...plan.items.map((item) => `- ${itemLine(item)}`));
|
|
107
110
|
}
|
|
108
111
|
if (plan.studies.length) {
|
|
109
|
-
lines.push('Estudios:', ...plan.studies.map((study) => `- ${study
|
|
112
|
+
lines.push('Estudios:', ...plan.studies.map((study) => `- ${studyLine(study)}`));
|
|
110
113
|
}
|
|
111
114
|
if (plan.homeCare.length) lines.push('Cuidados en casa:', ...plan.homeCare.map((entry) => `- ${entry}`));
|
|
112
115
|
if (plan.note) lines.push(`Nota del médico: «${plan.note}».`);
|
|
113
116
|
if (plan.followUp.kind !== 'none') lines.push(FOLLOW_UP_LINES[plan.followUp.kind](plan.followUp.hours));
|
|
117
|
+
if (needsPrescriber(plan)) lines.push(PRESCRIPTION_LINE);
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
|
|
122
|
+
function assistantInstructionsOf(plan) {
|
|
123
|
+
if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
|
|
124
|
+
return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// What the team's case shows as the indication, in the doctor's terms rather than the assistant's.
|
|
128
|
+
function planSummary(plan) {
|
|
129
|
+
const lines = [...plan.items.map(itemLine), ...plan.studies.map((study) => `Estudio: ${studyLine(study)}`)];
|
|
130
|
+
if (plan.homeCare.length) lines.push(`Cuidados en casa: ${plan.homeCare.join('; ')}.`);
|
|
131
|
+
if (plan.note) lines.push(`Nota: ${plan.note}`);
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The receta the Airtable automations turn into the doctor's PDF.
|
|
136
|
+
function prescriptionText(plan) {
|
|
137
|
+
const lines = plan.items.map(itemLine);
|
|
138
|
+
if (plan.studies.length) lines.push('Estudios:', ...plan.studies.map(studyLine));
|
|
114
139
|
return lines.join('\n');
|
|
115
140
|
}
|
|
116
141
|
|
|
@@ -158,8 +183,11 @@ function proposedPlanOf(draft) {
|
|
|
158
183
|
|
|
159
184
|
module.exports = {
|
|
160
185
|
approvedInstructions,
|
|
186
|
+
assistantInstructionsOf,
|
|
161
187
|
needsPrescriber,
|
|
162
188
|
planInstructions,
|
|
163
189
|
planOf,
|
|
190
|
+
planSummary,
|
|
191
|
+
prescriptionText,
|
|
164
192
|
proposedPlanOf,
|
|
165
193
|
};
|
|
@@ -342,7 +342,7 @@ class BaseLLMProvider {
|
|
|
342
342
|
'- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. Si en este turno le indicaste un medicamento, una medida de autocuidado o que contacte a alguien (dentista, su médico, urgencias), o le transmitiste un tratamiento que indicó el equipo, decide el seguimiento en este mismo turno: si no hay ya un seguimiento activo que lo cubra, programa uno a 24–48 h (una red abierta no lo impide: la red espera su dato y el seguimiento pregunta por lo indicado) con utility_basis agreed_action: si fue un medicamento o una medida, para saber si funcionó; si fue una cita o un estudio, para saber si pudo agendarlo o si ya fue y cuándo. Pedirle «avísame cómo te va» no sustituye ese seguimiento. No lo programes por inactividad sola. Si la paciente vuelve a escribir antes, el seguimiento sigue pendiente y lo verás en «Seguimientos ya programados»: mantenlo, actualízalo o cancélalo según lo que cambió.',
|
|
343
343
|
'- RECORDATORIO: sólo existe si la paciente pidió explícitamente que se lo recuerdes o aceptó explícitamente tu oferta de recordárselo. Usa schedulePatientReminder con el tipo concreto (medicamento, cita, examen u otros) y exactamente a la hora que pidió. No lo adelantes para que llegue antes de una cita o tarea salvo que ella haya pedido ese adelanto. No infieras un recordatorio porque mencionó o agendó algo, ni lo uses para preguntar cómo sigue o vigilar incertidumbre clínica.',
|
|
344
344
|
'- CONTINGENCIA: protege una incertidumbre clínica todavía abierta; falta un dato u observación que podría cambiar el grado, la urgencia o la conducta. Usa setContingencyPlan: la contingencia define qué se espera, cuánto silencio tolerar y, si corresponde, la escalada posterior. Nunca la sustituyas por seguimiento ni por recordatorio.',
|
|
345
|
-
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene.',
|
|
345
|
+
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene. Lo mismo vale para un medicamento o una receta que indicó el equipo: el seguimiento sobre si lo consiguió o pasó por ella no espera a que se cierre la red.',
|
|
346
346
|
].join('\n'),
|
|
347
347
|
}];
|
|
348
348
|
}
|
|
@@ -445,19 +445,20 @@ class BaseLLMProvider {
|
|
|
445
445
|
parts.push(
|
|
446
446
|
'', '## Check-ins que ya le llegaron en las últimas 48 h', ...(sent.length ? sent : ['- ninguno']),
|
|
447
447
|
'',
|
|
448
|
-
`Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes,
|
|
448
|
+
`Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes, compara lo que querías saber con todo lo que dijo sobre este tema, incluido lo que te contó justo antes de que quedara pendiente:`,
|
|
449
449
|
'- Este check-in es el seguimiento que está venciendo ahora, aunque lo veas programado unos minutos más tarde: ya no está pendiente y no se puede actualizar. Si decides esperar, programa uno nuevo.',
|
|
450
|
-
'- Si ya lo
|
|
450
|
+
'- Si ya te dio lo que querías saber, a ti o al equipo, no se lo vuelvas a preguntar como si faltara. Si es una red de contingencia y ya no hace falta revisarlo, resuélvela con setContingencyPlan y registra hold con hold_reason answered_since_armed. Si todavía importa saber si empeoró, deja la red como está, empieza por lo que te dijo y pregunta sólo si cambió («Me dijiste que…» o «Me dijo que…», según cómo le hables). Si sólo te dio parte del dato, lo que falta sigue pendiente.',
|
|
451
451
|
'- Si tu último mensaje ya le hace esta pregunta o la cubre, y hoy estuvo en la conversación y dejó de contestar hace menos de 6 h, no la repitas hoy: programa la siguiente vez o resuélvela si ya no hace falta.',
|
|
452
452
|
'- Si sólo quedó una pregunta tuya sin respuesta y lleva 8 h o más en silencio, retomarla es justo para lo que existe este check-in: escríbele, salvo que sea de noche.',
|
|
453
453
|
'- Fuera de esos casos, decide la siguiente vez según el tipo de seguimiento y lo que ha ido contando: lo que puede empeorar en horas se pregunta pronto; un síntoma estable o ya en manejo puede esperar días. Si ya le llegó otro check-in hoy y esto puede esperar, no le escribas hoy: programa con schedulePatientReminder (reminder_type seguimiento, con la utility_basis de lo que quedó pendiente, nunca entre 07:30 y 11:00) la hora que corresponda y registra hold con hold_reason rescheduled_for_cadence y el mismo next_check_at.',
|
|
454
|
-
'- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle.',
|
|
454
|
+
'- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle. Esta red espera cómo evolucionó el síntoma: lo que reportó al abrirla no lo responde.',
|
|
455
455
|
'- Este check-in es el seguimiento que vencía: no cuenta como otro pendiente. Si un seguimiento distinto en la lista de pendientes, o una red activa, ya va a preguntar esto, no programes otro: registra hold con next_check_at null y hold_reason contingency_already_armed.',
|
|
456
456
|
'- Si le escribes, un solo mensaje con una sola pregunta; si otro tema pendiente se responde con esa misma pregunta, intégralo y resuelve las redes que ese mensaje ya cubre. Registra en utility_basis lo que contó o acordó.',
|
|
457
|
+
'- Si tu último mensaje dejó una pregunta distinta sin contestar que no cambia el grado ni la urgencia (por ejemplo, cuándo pasa por una receta que indicó el equipo), no la pierdas: le escribas o no, antes de cerrar el turno y de DeliverPatientMessage programa esa pregunta con schedulePatientReminder (reminder_type seguimiento, con la utility_basis que corresponda, al menos 4 h después y nunca entre 07:30 y 11:00). Si una red activa ya la espera, no programes nada. Si ya hay un seguimiento pendiente, no crees otro ni le sumes preguntas. Si en este turno reprogramas este check-in, sólo cabe un seguimiento: programa el de este check-in.',
|
|
457
458
|
exchange?.sessionOpen === false
|
|
458
459
|
? 'Si escribes, tu texto llega dentro de una plantilla que ya empieza con «Hola {su nombre},»: no saludes ni escribas su nombre; entra directo a lo que quieres saber.'
|
|
459
460
|
: 'Si escribes, le llega como un mensaje normal. Si llevan horas sin hablar, ábrelo con un saludo breve y natural según la hora del día, con el nombre de quien lleva la conversación si lo conoces (a un cuidador cuyo nombre no conoces, sin nombre); luego entra a lo que quieres saber.',
|
|
460
|
-
'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, si tu
|
|
461
|
+
'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, sólo si tu pregunta sigue sin respuesta y tu último mensaje fue esa pregunta, dilo en pocas palabras («te había preguntado por…» o «le había preguntado por…», según cómo le hables). Si ya te respondió, no digas que se lo habías preguntado: retoma lo que te dijo. Esa única pregunta va por lo que sigue faltando de lo que esperabas saber o por si cambió lo que ya te dijo; si lo que querías preguntarle trae varias, elige la que más importa hoy. Háblale directo a quien lleva la conversación, de tú o de usted, como en la conversación; nunca hables en tercera persona de quien te está leyendo.',
|
|
461
462
|
'No le comentes como novedad algo que no te dijo en el chat: sus respuestas del cuestionario las conoces tú, no son algo que te acaba de contar. No repitas lo que ya le dijiste (por ejemplo, que el equipo ya tiene el aviso) ni des por hecho detalles que no te dio.',
|
|
462
463
|
'Breve, sin listas de señales de alarma y sin mencionar recordatorios, planes ni seguimientos programados.',
|
|
463
464
|
);
|
|
@@ -521,7 +522,7 @@ class BaseLLMProvider {
|
|
|
521
522
|
const dueNet = outreachBrief?.checkIn?.planId || null;
|
|
522
523
|
const waiting = active.map(({ carePlan: plan = {} } = {}) => {
|
|
523
524
|
let fired = '';
|
|
524
|
-
if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado.';
|
|
525
|
+
if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado. Decide esta red con las reglas del check-in, no con la decisión de abajo.';
|
|
525
526
|
else if (plan.status === 'fired_reminder') fired = ' Su check-in ya se envió.';
|
|
526
527
|
return `- ${plan.planId} (armada ${localFireTime(plan.createdAt)}) espera: ${sanitizeInlineText(plan.awaitingCharacterization)}.${fired}`;
|
|
527
528
|
});
|
|
@@ -5,7 +5,7 @@ const { logger } = require('../../utils/logger');
|
|
|
5
5
|
|
|
6
6
|
const definition = {
|
|
7
7
|
name: 'getPatientRiskAssessment',
|
|
8
|
-
description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
|
|
8
|
+
description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `plan_approved` = the doctor gave a structured plan, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
|
|
9
9
|
strict: false,
|
|
10
10
|
replayScope: 'turn',
|
|
11
11
|
parameters: {
|
|
@@ -12,7 +12,7 @@ const MAX_PATIENT_SILENCE_MIN = 1380;
|
|
|
12
12
|
|
|
13
13
|
const definition = {
|
|
14
14
|
name: 'setContingencyPlan',
|
|
15
|
-
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
15
|
+
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. The same goes for a medication or prescription the team indicated: schedule the seguimiento about getting it without waiting for the net to close. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
16
16
|
strict: false,
|
|
17
17
|
parameters: {
|
|
18
18
|
type: 'object',
|
|
@@ -54,8 +54,8 @@ const openEscalationReviewController = async (req, res) => {
|
|
|
54
54
|
|
|
55
55
|
const getEscalationReviewController = async (req, res) => {
|
|
56
56
|
try {
|
|
57
|
-
const { review, isApprover, responseDeadline, proposedPlan } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
58
|
-
res.status(200).json({ success: true, review: withCaseStatus(review), isApprover, responseDeadline, proposedPlan });
|
|
57
|
+
const { review, isApprover, responseDeadline, proposedPlan, prescribers } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
58
|
+
res.status(200).json({ success: true, review: withCaseStatus(review), isApprover, responseDeadline, proposedPlan, prescribers });
|
|
59
59
|
} catch (error) {
|
|
60
60
|
respondWithError(res, error, 'reading escalation review', { reviewId: req.params?.id });
|
|
61
61
|
}
|
|
@@ -187,6 +187,7 @@ const recordEscalationReviewDecisionController = async (req, res) => {
|
|
|
187
187
|
finalText = null,
|
|
188
188
|
plan = null,
|
|
189
189
|
instructions = null,
|
|
190
|
+
prescriberEmail = null,
|
|
190
191
|
comment = null,
|
|
191
192
|
doctorName = null,
|
|
192
193
|
doctorReply = null,
|
|
@@ -202,6 +203,7 @@ const recordEscalationReviewDecisionController = async (req, res) => {
|
|
|
202
203
|
finalText,
|
|
203
204
|
plan,
|
|
204
205
|
instructions,
|
|
206
|
+
prescriberEmail,
|
|
205
207
|
comment,
|
|
206
208
|
doctorName,
|
|
207
209
|
doctorReply,
|
|
@@ -2,13 +2,7 @@ const { downloadFileFromS3 } = require('../config/awsConfig');
|
|
|
2
2
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
3
|
|
|
4
4
|
const { logger } = require('../utils/logger');
|
|
5
|
-
|
|
6
|
-
const MIME_MAP = {
|
|
7
|
-
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
|
|
8
|
-
pdf: 'application/pdf',
|
|
9
|
-
mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
|
|
10
|
-
mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
|
|
11
|
-
};
|
|
5
|
+
const { EXTENSION_MIME_TYPES } = require('../utils/mediaValidator');
|
|
12
6
|
|
|
13
7
|
const getMediaController = async (req, res) => {
|
|
14
8
|
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
@@ -48,7 +42,7 @@ const getMediaController = async (req, res) => {
|
|
|
48
42
|
let contentType = fileData.ContentType || 'application/octet-stream';
|
|
49
43
|
if (contentType === 'application/octet-stream') {
|
|
50
44
|
const ext = mediaKey.split('.').pop().toLowerCase();
|
|
51
|
-
contentType =
|
|
45
|
+
contentType = EXTENSION_MIME_TYPES[ext] || contentType;
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
const { Monitoreo_ID } = require('../config/airtableConfig');
|
|
2
|
-
|
|
3
1
|
const { logger } = require('../utils/logger');
|
|
4
2
|
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const { addLinkedRecord } = require('../services/airtableService');
|
|
3
|
+
const { createPrescription } = require('../services/prescriptionService');
|
|
8
4
|
|
|
9
5
|
const RECORD_ID_RX = /^rec[A-Za-z0-9]{14}$/;
|
|
10
6
|
|
|
11
|
-
// physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
|
|
12
7
|
const createPrescriptionController = async (req, res) => {
|
|
13
|
-
const { prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
|
|
14
|
-
let code = req.body.code;
|
|
8
|
+
const { code, prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
|
|
15
9
|
|
|
16
10
|
if (!code) return res.status(400).json({ success: false, error: 'Code is required' });
|
|
17
11
|
if (!prescription) return res.status(400).json({ success: false, error: 'Prescription is required' });
|
|
@@ -21,28 +15,10 @@ const createPrescriptionController = async (req, res) => {
|
|
|
21
15
|
}
|
|
22
16
|
|
|
23
17
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const record = await addLinkedRecord(
|
|
27
|
-
Monitoreo_ID,
|
|
28
|
-
'patient_prescriptions',
|
|
29
|
-
{
|
|
30
|
-
prescription,
|
|
31
|
-
author_email: triggeredBy,
|
|
32
|
-
creation_source: creationSource,
|
|
33
|
-
created: new Date().toISOString(),
|
|
34
|
-
...(physicianId && { physician: [physicianId] })
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
referenceTable: 'estado_general',
|
|
38
|
-
referenceFilter: `{whatsapp_id}='${code}'`,
|
|
39
|
-
linkFieldName: 'patient_id'
|
|
40
|
-
},
|
|
41
|
-
code
|
|
42
|
-
);
|
|
18
|
+
const recordId = await createPrescription({ code, prescription, authorEmail: triggeredBy, creationSource, physicianId });
|
|
43
19
|
|
|
44
20
|
logger.info('[PrescriptionController] Prescription created', { code, physicianId });
|
|
45
|
-
res.status(201).json({ success: true, message: 'Prescription created', recordId
|
|
21
|
+
res.status(201).json({ success: true, message: 'Prescription created', recordId });
|
|
46
22
|
} catch (error) {
|
|
47
23
|
logger.error('[PrescriptionController] Error creating prescription', { error: error.message, code });
|
|
48
24
|
res.status(500).json({ success: false, error: 'Failed to create prescription' });
|
|
@@ -187,8 +187,7 @@ class NexusMessaging {
|
|
|
187
187
|
|
|
188
188
|
this.scheduledMessageJob = new ScheduledMessageJob({
|
|
189
189
|
queueAdapter: this.queueAdapter,
|
|
190
|
-
sendMessage: this.sendMessage.bind(this)
|
|
191
|
-
requireProvider: () => this.getProvider()
|
|
190
|
+
sendMessage: this.sendMessage.bind(this)
|
|
192
191
|
});
|
|
193
192
|
|
|
194
193
|
this.templateApprovalJob = new TemplateApprovalJob({
|
|
@@ -433,9 +432,13 @@ class NexusMessaging {
|
|
|
433
432
|
|
|
434
433
|
if (messageData.fileUrl && this._providerStores) await prepareMessageMedia(messageData);
|
|
435
434
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
435
|
+
const storesTemplateMedia = Boolean(messageData.contentSid && !messageData.fileUrl && !messageData.parentMessageId && this.provider.renderTemplateContent);
|
|
436
|
+
if (messageData.contentSid && (!messageData.body || storesTemplateMedia)) {
|
|
437
|
+
const content = this.provider.renderTemplateContent
|
|
438
|
+
? await this.provider.renderTemplateContent(messageData.contentSid, messageData.variables)
|
|
439
|
+
: { body: await this.provider.renderTemplate?.(messageData.contentSid, messageData.variables) };
|
|
440
|
+
if (!messageData.body && content?.body) messageData.body = content.body;
|
|
441
|
+
if (storesTemplateMedia && content?.media) messageData.media = { ...content.media, caption: messageData.body || '' };
|
|
439
442
|
}
|
|
440
443
|
|
|
441
444
|
if (this._needsTemplateRoute(messageData) && !(await isWithin24HourWindow(messageData.code, { contactCode: messageData.contactCode }))) {
|
|
@@ -8,8 +8,9 @@ const { uploadBufferToS3, generatePresignedUrl } = require('../config/awsConfig'
|
|
|
8
8
|
const runtimeConfig = require('../config/runtimeConfig');
|
|
9
9
|
|
|
10
10
|
const { sanitizeMediaFilename } = require('../utils/sanitizerUtils.js');
|
|
11
|
-
const { validateMedia, getMediaType, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
|
|
11
|
+
const { validateMedia, getMediaType, EXTENSION_MIME_TYPES, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
|
|
12
12
|
const { logger } = require('../utils/logger');
|
|
13
|
+
const { ownS3Location } = require('../utils/s3Utils');
|
|
13
14
|
|
|
14
15
|
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
15
16
|
|
|
@@ -177,9 +178,18 @@ function getFileExtension(contentType) {
|
|
|
177
178
|
return mimeToExt[contentType] || 'bin';
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
function templateMediaOf(mediaUrl) {
|
|
182
|
+
const location = ownS3Location(mediaUrl);
|
|
183
|
+
if (!location?.key) return null;
|
|
184
|
+
const fileName = path.basename(location.key);
|
|
185
|
+
const contentType = EXTENSION_MIME_TYPES[path.extname(fileName).slice(1)] || 'application/octet-stream';
|
|
186
|
+
return { ...location, contentType, mediaType: getMediaType(contentType), fileName };
|
|
187
|
+
}
|
|
188
|
+
|
|
180
189
|
module.exports = {
|
|
181
190
|
uploadMediaToS3,
|
|
182
191
|
saveMediaToLocal,
|
|
183
192
|
prepareMessageMedia,
|
|
184
|
-
getFileExtension
|
|
193
|
+
getFileExtension,
|
|
194
|
+
templateMediaOf
|
|
185
195
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -274,7 +274,7 @@ declare module '@peopl-health/nexus' {
|
|
|
274
274
|
initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
|
|
275
275
|
getWorkflowRunner(): WorkflowRunner;
|
|
276
276
|
processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string; beforeRun?: () => Promise<string | null | undefined> | string | null | undefined; dedupeWithinMs?: number }): Promise<{ outcome: string | null; reason?: string; error?: string }>;
|
|
277
|
-
processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<
|
|
277
|
+
processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<{ delivery: Record<string, unknown> | null; sendThrew: boolean } | undefined>;
|
|
278
278
|
processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean; replyToContact?: boolean; allowFollowUp?: boolean }): Promise<OutreachDecision>;
|
|
279
279
|
processCheckInDue(code: string, options: { reminderId: string; type: 'contingencia' | 'seguimiento'; question?: string; firstName?: string | null }): Promise<CheckInDueResult>;
|
|
280
280
|
processScheduledFollowUp(code: string, options: { reminderId: string; firstName: string; now?: Date }): Promise<ScheduledFollowUpResult>;
|
|
@@ -23,13 +23,12 @@ const CANCELLED_SEND_MESSAGES = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
class ScheduledMessageJob extends BaseJob {
|
|
26
|
-
constructor({ queueAdapter, sendMessage
|
|
26
|
+
constructor({ queueAdapter, sendMessage } = {}) {
|
|
27
27
|
super({ queueAdapter, queueName: QUEUE_NAME });
|
|
28
28
|
if (typeof sendMessage !== 'function') {
|
|
29
29
|
throw new Error('ScheduledMessageJob requires a sendMessage function');
|
|
30
30
|
}
|
|
31
31
|
this.sendMessage = sendMessage;
|
|
32
|
-
this.requireProvider = requireProvider;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
async schedule({ scheduledMessageId, sendTime }) {
|
|
@@ -101,11 +100,6 @@ class ScheduledMessageJob extends BaseJob {
|
|
|
101
100
|
|
|
102
101
|
try {
|
|
103
102
|
const parentMessageId = msg.parentMessageId || null;
|
|
104
|
-
if (!parentMessageId && msg.contentSid && !msg.message) {
|
|
105
|
-
const provider = this.requireProvider?.();
|
|
106
|
-
const rendered = await provider?.renderTemplate?.(msg.contentSid, msg.variables);
|
|
107
|
-
if (rendered) msg.message = rendered;
|
|
108
|
-
}
|
|
109
103
|
|
|
110
104
|
const result = await this.sendMessage({
|
|
111
105
|
code: msg.code,
|
|
@@ -219,6 +219,10 @@ const planSchema = new mongoose.Schema({
|
|
|
219
219
|
},
|
|
220
220
|
instructions: { type: String, required: true },
|
|
221
221
|
instructionsEdited: { type: Boolean, default: false },
|
|
222
|
+
prescriber: {
|
|
223
|
+
type: new mongoose.Schema({ name: { type: String, default: null }, email: { type: String, required: true } }, { _id: false }),
|
|
224
|
+
default: null,
|
|
225
|
+
},
|
|
222
226
|
}, { _id: false });
|
|
223
227
|
|
|
224
228
|
const decisionSchema = new mongoose.Schema({
|
|
@@ -239,7 +243,8 @@ const patientDeliverySchema = new mongoose.Schema({
|
|
|
239
243
|
decisionNumber: { type: Number, required: true },
|
|
240
244
|
messageId: { type: String, default: null },
|
|
241
245
|
status: { type: String, default: null },
|
|
242
|
-
transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template'], required: true },
|
|
246
|
+
transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template', 'assistant'], required: true },
|
|
247
|
+
prescriptionRecordId: { type: String, default: null },
|
|
243
248
|
deferred: { type: Boolean, default: false },
|
|
244
249
|
confirmedBy: { type: String, default: null },
|
|
245
250
|
confirmedAt: { type: Date, default: null },
|
|
@@ -26,9 +26,9 @@ const ASSISTANT_MESSAGE_FAILURES = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
class AssistantMessageError extends Error {
|
|
29
|
-
constructor(reasonCode, acceptedCount = 0) {
|
|
29
|
+
constructor(reasonCode, acceptedCount = 0, cause = undefined) {
|
|
30
30
|
const [statusCode, message] = ASSISTANT_MESSAGE_FAILURES[reasonCode];
|
|
31
|
-
super(message);
|
|
31
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
32
32
|
this.reasonCode = reasonCode;
|
|
33
33
|
this.statusCode = statusCode;
|
|
34
34
|
this.acceptedCount = acceptedCount;
|
|
@@ -117,8 +117,10 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
|
|
|
117
117
|
if (!reply) return;
|
|
118
118
|
recordPatientSend?.({ code, channel: 'assistant_instruction', sentBy: triggeredBy, text: input.messages.join('\n') });
|
|
119
119
|
|
|
120
|
+
let turn;
|
|
121
|
+
let settled = null;
|
|
120
122
|
try {
|
|
121
|
-
await executeWithPipeline(code, 'system', 'queue',
|
|
123
|
+
turn = await executeWithPipeline(code, 'system', 'queue',
|
|
122
124
|
async (preProcessResult, shouldContinue, runId) => {
|
|
123
125
|
return await assistantProcessor.process({
|
|
124
126
|
code,
|
|
@@ -131,11 +133,15 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
|
|
|
131
133
|
signal: getAbortSignal(runId),
|
|
132
134
|
}
|
|
133
135
|
});
|
|
134
|
-
}
|
|
136
|
+
},
|
|
137
|
+
{ onSendSettled: async (outcome) => { settled = outcome; } },
|
|
135
138
|
);
|
|
136
|
-
} catch {
|
|
137
|
-
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// The cause stays internal: callers tell a drain that refused the turn from one that may have answered.
|
|
141
|
+
throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount, error);
|
|
138
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.
|
|
144
|
+
return { delivery: turn?.delivery ?? null, sendThrew: settled === 'threw' };
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
module.exports = { AssistantMessageError, injectAssistantMessages, processAssistantSystemMessage };
|
|
@@ -33,7 +33,9 @@ const { doctorAlertHeadline, doctorAlertOpener, renderDoctorMessage, templateSum
|
|
|
33
33
|
const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
|
|
34
34
|
const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
|
|
35
35
|
const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
|
|
36
|
-
const {
|
|
36
|
+
const {
|
|
37
|
+
approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, proposedPlanOf,
|
|
38
|
+
} = require('../clinical/helpers/doctorPlanHelper');
|
|
37
39
|
const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
|
|
38
40
|
const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
|
|
39
41
|
const {
|
|
@@ -53,6 +55,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
|
|
|
53
55
|
const { MAX_NOTE_LENGTH, openCaseIds, readPatientCases, updatePatientTask } = require('./patientTaskService');
|
|
54
56
|
const { countMessages } = require('./messageService');
|
|
55
57
|
const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
|
|
58
|
+
const { createPrescription } = require('./prescriptionService');
|
|
56
59
|
const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
|
|
57
60
|
const { effectiveUrgencyOf, levelsOf, patientTimerMinutes, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
|
|
58
61
|
const { caseTaskLive, scheduleCaseSync } = require('./escalationCaseTaskService');
|
|
@@ -106,7 +109,6 @@ const SAME_URGENCY = 'The review is already at that urgency';
|
|
|
106
109
|
const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
|
|
107
110
|
const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
|
|
108
111
|
const APPROVALS = ['approved', 'edited_approved', 'plan_approved'];
|
|
109
|
-
const PRESCRIBER_ONLY = 'Only an approving doctor can indicate drugs or studies, or edit the instructions to the assistant';
|
|
110
112
|
const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
|
|
111
113
|
const STALE_HANDOFF = 'New information reached the review after the draft the doctor received; it went back to documenting for a new draft';
|
|
112
114
|
const DRAFT_CHANGED = 'A newer draft was written after the one you saw; read it before deciding';
|
|
@@ -730,16 +732,31 @@ async function isEscalationApprover(operator) {
|
|
|
730
732
|
}
|
|
731
733
|
}
|
|
732
734
|
|
|
733
|
-
//
|
|
734
|
-
async function
|
|
735
|
-
|
|
735
|
+
// 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.
|
|
736
|
+
async function prescriberOf(email, operator) {
|
|
737
|
+
if (email != null && typeof email !== 'string') throw httpError(400, 'prescriberEmail must be text');
|
|
738
|
+
let approvers;
|
|
736
739
|
try {
|
|
737
|
-
|
|
740
|
+
approvers = await listEscalationApprovers();
|
|
738
741
|
} catch (error) {
|
|
739
|
-
logger.error('[EscalationReview]
|
|
742
|
+
logger.error('[EscalationReview] The approving doctors could not be listed; the plan was not recorded', { error: redactedError(error) });
|
|
740
743
|
throw httpError(503, 'The approving doctors could not be read; try again');
|
|
741
744
|
}
|
|
742
|
-
if (!
|
|
745
|
+
if (!approvers.length) throw httpError(409, 'No physician signs recetas: tick aprueba_vaiven on a physician');
|
|
746
|
+
const wanted = (email?.trim() || operator).trim().toLowerCase();
|
|
747
|
+
const chosen = approvers.find((approver) => approver.email === wanted) ?? (!email?.trim() && approvers.length === 1 ? approvers[0] : null);
|
|
748
|
+
if (!chosen) throw httpError(400, `prescriberEmail must be one of: ${approvers.map((approver) => approver.email).join(', ')}`);
|
|
749
|
+
return { name: chosen.name, email: chosen.email };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Who can sign a receta, for whoever records the plan to pick; null when the list could not be read, which the read survives.
|
|
753
|
+
async function prescribersForRead() {
|
|
754
|
+
try {
|
|
755
|
+
return (await listEscalationApprovers()).map(({ name, email }) => ({ name, email }));
|
|
756
|
+
} catch (error) {
|
|
757
|
+
logger.warn('[EscalationReview] The approving doctors could not be listed for the review', { error: redactedError(error) });
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
743
760
|
}
|
|
744
761
|
|
|
745
762
|
async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
@@ -768,6 +785,7 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
|
768
785
|
isApprover,
|
|
769
786
|
responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()),
|
|
770
787
|
proposedPlan: draft ? { draftNumber: review.drafts.length, ...proposedPlanOf(draft) } : null,
|
|
788
|
+
prescribers: draft ? await prescribersForRead() : [],
|
|
771
789
|
};
|
|
772
790
|
}
|
|
773
791
|
|
|
@@ -1815,6 +1833,7 @@ async function recordDoctorDecision({
|
|
|
1815
1833
|
finalText = null,
|
|
1816
1834
|
plan: requestedPlan = null,
|
|
1817
1835
|
instructions = null,
|
|
1836
|
+
prescriberEmail = null,
|
|
1818
1837
|
comment = null,
|
|
1819
1838
|
doctorName = null,
|
|
1820
1839
|
doctorReply = null,
|
|
@@ -1850,9 +1869,12 @@ async function recordDoctorDecision({
|
|
|
1850
1869
|
if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
|
|
1851
1870
|
plan = planOf(requestedPlan);
|
|
1852
1871
|
plan = { ...plan, ...approvedInstructions(plan, instructions) };
|
|
1853
|
-
//
|
|
1872
|
+
// 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.
|
|
1854
1873
|
const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
|
|
1855
|
-
if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug)
|
|
1874
|
+
if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) {
|
|
1875
|
+
plan = { ...plan, prescriber: await prescriberOf(prescriberEmail, operator) };
|
|
1876
|
+
decidedBy = plan.prescriber.email;
|
|
1877
|
+
}
|
|
1856
1878
|
}
|
|
1857
1879
|
|
|
1858
1880
|
const review = await loadReview(reviewId);
|
|
@@ -2033,10 +2055,10 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
|
|
|
2033
2055
|
const claim = newOperationClaim();
|
|
2034
2056
|
const claimed = await updateReview(reviewId, SENDABLE_STATUSES, { $set: { status: 'sending', operationClaim: claim } });
|
|
2035
2057
|
const decisionNumber = claimed.decisions.length;
|
|
2036
|
-
const { finalText, decision } = claimed.decisions[decisionNumber - 1];
|
|
2058
|
+
const { finalText, decision, plan } = claimed.decisions[decisionNumber - 1];
|
|
2037
2059
|
const claimedFrom = statusAfter(decision);
|
|
2038
2060
|
|
|
2039
|
-
if (!finalText) {
|
|
2061
|
+
if (!finalText && !plan) {
|
|
2040
2062
|
await releaseReview(reviewId, 'sending', claimedFrom, claim).catch((releaseError) => {
|
|
2041
2063
|
logger.error('[EscalationReview] The claim was not released after a decision with no text', {
|
|
2042
2064
|
escalationReviewId: String(claimed._id), error: releaseError.message,
|
|
@@ -2054,6 +2076,7 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
|
|
|
2054
2076
|
});
|
|
2055
2077
|
throw httpError(409, 'The agent characterized the case after this decision; it went back to documenting for a new draft and decision');
|
|
2056
2078
|
}
|
|
2079
|
+
if (plan) return sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId });
|
|
2057
2080
|
|
|
2058
2081
|
// Built outside the try: composing reads Mongo and Airtable, and failing there never sent.
|
|
2059
2082
|
let message;
|
|
@@ -2109,6 +2132,98 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
|
|
|
2109
2132
|
return syncCase(review, operator, requestId);
|
|
2110
2133
|
}
|
|
2111
2134
|
|
|
2135
|
+
// The PDF goes out from the Airtable automations; a failed receta never undoes the message already sent.
|
|
2136
|
+
async function issuePrescription(review, plan, prescriberEmail) {
|
|
2137
|
+
try {
|
|
2138
|
+
return await createPrescription({
|
|
2139
|
+
code: review.patientCode, prescription: prescriptionText(plan), authorEmail: prescriberEmail, creationSource: 'patient case',
|
|
2140
|
+
});
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
logger.error('[EscalationReview] The prescription was not created after the plan was sent', {
|
|
2143
|
+
escalationReviewId: String(review._id), error: redactedError(error),
|
|
2144
|
+
});
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// How the assistant's reply left: sent, queued for the patient's reply window, not sent, or unknown.
|
|
2150
|
+
function replyOutcome(delivery, sendThrew) {
|
|
2151
|
+
if (sendThrew) return 'unknown';
|
|
2152
|
+
if (!delivery) return 'not_sent';
|
|
2153
|
+
if (delivery.success === true && delivery.deferred === true) return 'queued';
|
|
2154
|
+
if (delivery.success === true && typeof delivery.messageId === 'string' && delivery.messageId) return 'sent';
|
|
2155
|
+
if (delivery.success === false && delivery.deferred !== true && delivery.duplicate !== true) return 'not_sent';
|
|
2156
|
+
return 'unknown';
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen.
|
|
2160
|
+
async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
|
|
2161
|
+
const reviewId = claimed._id;
|
|
2162
|
+
const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
|
|
2163
|
+
logger.error('[EscalationReview] The claim was not released after a plan the assistant did not send', {
|
|
2164
|
+
escalationReviewId: String(reviewId), error: releaseError.message,
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
const prescriberEmail = needsPrescriber(plan) ? plan.prescriber?.email ?? null : null;
|
|
2169
|
+
let delivery;
|
|
2170
|
+
let sendThrew;
|
|
2171
|
+
try {
|
|
2172
|
+
if (needsPrescriber(plan) && !prescriberEmail) throw httpError(409, 'This plan names no doctor to sign the receta; record the plan again');
|
|
2173
|
+
({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
|
|
2174
|
+
claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
|
|
2175
|
+
) ?? {});
|
|
2176
|
+
} catch (error) {
|
|
2177
|
+
// A turn refused by a deploy's drain never ran; any other failure after the instructions were stored may have answered.
|
|
2178
|
+
if (error?.reasonCode === 'MESSAGE_REPLY_FAILED' && !(error.cause instanceof DrainingError)) {
|
|
2179
|
+
logger.error('[EscalationReview] The assistant turn failed after the instructions were stored; leaving the review in sending', {
|
|
2180
|
+
escalationReviewId: String(reviewId),
|
|
2181
|
+
});
|
|
2182
|
+
throw httpError(502, 'The assistant turn failed after the instructions were stored; check the chat before sending again');
|
|
2183
|
+
}
|
|
2184
|
+
await release();
|
|
2185
|
+
if (error?.cause instanceof DrainingError) throw httpError(503, 'The server is restarting; send the plan again in a minute');
|
|
2186
|
+
throw error;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
const outcome = replyOutcome(delivery, sendThrew);
|
|
2190
|
+
if (outcome === 'not_sent') {
|
|
2191
|
+
await release();
|
|
2192
|
+
throw httpError(502, 'The assistant did not send a message to the patient; check the chat and send the plan again');
|
|
2193
|
+
}
|
|
2194
|
+
if (outcome === 'unknown') {
|
|
2195
|
+
logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
|
|
2196
|
+
throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
|
|
2200
|
+
const queued = outcome === 'queued';
|
|
2201
|
+
const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
|
|
2202
|
+
const record = {
|
|
2203
|
+
decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
|
|
2204
|
+
...(prescriptionRecordId && { prescriptionRecordId }),
|
|
2205
|
+
};
|
|
2206
|
+
const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
|
|
2207
|
+
const status = queued ? 'indications_queued' : 'indications_sent';
|
|
2208
|
+
const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
|
|
2209
|
+
const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
|
|
2210
|
+
await audit(review, operator, requestId, {
|
|
2211
|
+
action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
|
|
2212
|
+
});
|
|
2213
|
+
// Closing waits for a sent reply and the receta it promised; otherwise the team still has something to do.
|
|
2214
|
+
if (plan.followUp.kind === 'none' && !queued && !prescriptionFailed) {
|
|
2215
|
+
try {
|
|
2216
|
+
return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
logger.error('[EscalationReview] The review was not closed after its plan was sent', {
|
|
2219
|
+
escalationReviewId: String(reviewId), error: redactedError(error),
|
|
2220
|
+
});
|
|
2221
|
+
return syncCase(await loadReview(reviewId), operator, requestId);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return syncCase(review, operator, requestId);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2112
2227
|
const EPISODE_TRUST = 0.7;
|
|
2113
2228
|
const EPISODE_VARIABLE = 'intercurrent_events_summary';
|
|
2114
2229
|
const EPISODE_SOURCE = 'doctor';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const { Estado_General_ID, Monitoreo_ID } = require('../config/airtableConfig');
|
|
2
|
-
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
2
|
|
|
4
3
|
const { httpError, requiredText } = require('../utils/httpErrorUtils');
|
|
5
4
|
const { inlineOf } = require('../utils/coercionUtils');
|
|
6
5
|
const { escapeRegExp } = require('../utils/textMatchUtils');
|
|
6
|
+
const { ownS3Location } = require('../utils/s3Utils');
|
|
7
7
|
const { logger } = require('../utils/logger');
|
|
8
8
|
const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
9
9
|
const { OfficeReadMarker } = require('../models/officeReadMarkerModel');
|
|
@@ -194,20 +194,14 @@ async function readOfficeMessages({ requestedBy, code, limit }) {
|
|
|
194
194
|
|
|
195
195
|
function officeDocumentUrl(fileUrl) {
|
|
196
196
|
if (!fileUrl) return null;
|
|
197
|
-
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
198
197
|
let url = null;
|
|
199
198
|
try {
|
|
200
199
|
url = new URL(String(fileUrl));
|
|
201
200
|
} catch {
|
|
202
201
|
url = null;
|
|
203
202
|
}
|
|
204
|
-
// The approved document template pins the S3 host and takes the rest of the URL as a variable.
|
|
205
|
-
|
|
206
|
-
const bucket = escapeRegExp(bucketName ?? '');
|
|
207
|
-
const isVirtualHosted = new RegExp(`^${bucket}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url?.hostname ?? '');
|
|
208
|
-
const isPathStyle = /^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url?.hostname ?? '')
|
|
209
|
-
&& url.pathname.startsWith(`/${bucketName}/`);
|
|
210
|
-
if (!bucketName || url?.protocol !== 'https:' || !(isVirtualHosted || isPathStyle)) {
|
|
203
|
+
// The approved document template pins the S3 host and takes the rest of the URL as a variable.
|
|
204
|
+
if (!ownS3Location(url)) {
|
|
211
205
|
throw httpError(400, 'The document must be uploaded before it is sent');
|
|
212
206
|
}
|
|
213
207
|
return url;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const { Monitoreo_ID } = require('../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
4
|
+
|
|
5
|
+
const { addLinkedRecord } = require('./airtableService');
|
|
6
|
+
|
|
7
|
+
// physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
|
|
8
|
+
async function createPrescription({ code, prescription, authorEmail, creationSource = 'other', physicianId }) {
|
|
9
|
+
const patientCode = ensureWhatsAppFormat(code);
|
|
10
|
+
const record = await addLinkedRecord(
|
|
11
|
+
Monitoreo_ID,
|
|
12
|
+
'patient_prescriptions',
|
|
13
|
+
{
|
|
14
|
+
prescription,
|
|
15
|
+
author_email: authorEmail,
|
|
16
|
+
creation_source: creationSource,
|
|
17
|
+
created: new Date().toISOString(),
|
|
18
|
+
...(physicianId && { physician: [physicianId] })
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
referenceTable: 'estado_general',
|
|
22
|
+
referenceFilter: `{whatsapp_id}='${patientCode}'`,
|
|
23
|
+
linkFieldName: 'patient_id'
|
|
24
|
+
},
|
|
25
|
+
patientCode
|
|
26
|
+
);
|
|
27
|
+
return record?.id || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { createPrescription };
|
|
@@ -57,6 +57,13 @@ function validateMediaFormat(contentType, mediaType) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const EXTENSION_MIME_TYPES = {
|
|
61
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
|
|
62
|
+
pdf: 'application/pdf',
|
|
63
|
+
mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
|
|
64
|
+
mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
|
|
65
|
+
};
|
|
66
|
+
|
|
60
67
|
function getMediaType(contentType) {
|
|
61
68
|
for (const [type, formats] of Object.entries(ALLOWED_FORMATS)) {
|
|
62
69
|
if (formats.includes(contentType)) {
|
|
@@ -95,6 +102,7 @@ module.exports = {
|
|
|
95
102
|
validateMediaSize,
|
|
96
103
|
validateMediaFormat,
|
|
97
104
|
getMediaType,
|
|
105
|
+
EXTENSION_MIME_TYPES,
|
|
98
106
|
MEDIA_LIMITS,
|
|
99
107
|
ALLOWED_FORMATS,
|
|
100
108
|
STICKER_DIMENSIONS
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
const { escapeRegExp } = require('./textMatchUtils');
|
|
4
|
+
|
|
5
|
+
function decodedKey(rawKey) {
|
|
6
|
+
try {
|
|
7
|
+
return decodeURIComponent(rawKey);
|
|
8
|
+
} catch {
|
|
9
|
+
return rawKey;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A dotted bucket name is presigned path-style, so the bucket is then the first path segment.
|
|
14
|
+
function ownS3Location(value) {
|
|
15
|
+
let url = value;
|
|
16
|
+
try {
|
|
17
|
+
if (!(value instanceof URL)) url = new URL(String(value));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
22
|
+
if (!bucketName || url.protocol !== 'https:') return null;
|
|
23
|
+
if (new RegExp(`^${escapeRegExp(bucketName)}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url.hostname)) {
|
|
24
|
+
return { bucketName, key: decodedKey(url.pathname.slice(1)) };
|
|
25
|
+
}
|
|
26
|
+
if (/^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url.hostname) && url.pathname.startsWith(`/${bucketName}/`)) {
|
|
27
|
+
return { bucketName, key: decodedKey(url.pathname.slice(bucketName.length + 2)) };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { ownS3Location };
|