@peopl-health/nexus 6.4.0-dev.18676 → 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.
@@ -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'].caption || '';
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 || '';
@@ -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, revisa la conversación desde que quedó pendiente:`,
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 respondió, a ti o al equipo, no se lo vuelvas a preguntar. Si es una red de contingencia, resuélvela con setContingencyPlan y registra hold con hold_reason answered_since_armed.',
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 mensaje anterior ya se lo preguntó, dilo en pocas palabras («te había preguntado por…» o «le había preguntado por…», según cómo le hables). Esa única pregunta va por lo que sigue faltando de lo que esperabas saber; 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
+ '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
  });
@@ -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 = MIME_MAP[ext] || contentType;
45
+ contentType = EXTENSION_MIME_TYPES[ext] || contentType;
52
46
  }
53
47
 
54
48
  res.setHeader('Cache-Control', 'public, max-age=86400');
@@ -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
- if (messageData.contentSid && !messageData.body && this.provider.renderTemplate) {
437
- const rendered = await this.provider.renderTemplate(messageData.contentSid, messageData.variables);
438
- if (rendered) messageData.body = rendered;
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
  };
@@ -23,13 +23,12 @@ const CANCELLED_SEND_MESSAGES = {
23
23
  };
24
24
 
25
25
  class ScheduledMessageJob extends BaseJob {
26
- constructor({ queueAdapter, sendMessage, requireProvider = null } = {}) {
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({
@@ -109,7 +109,6 @@ const SAME_URGENCY = 'The review is already at that urgency';
109
109
  const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
110
110
  const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
111
111
  const APPROVALS = ['approved', 'edited_approved', 'plan_approved'];
112
- const PRESCRIBER_ONLY = 'Only an approving doctor can indicate drugs or studies, or edit the instructions to the assistant';
113
112
  const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
114
113
  const STALE_HANDOFF = 'New information reached the review after the draft the doctor received; it went back to documenting for a new draft';
115
114
  const DRAFT_CHANGED = 'A newer draft was written after the one you saw; read it before deciding';
@@ -733,16 +732,31 @@ async function isEscalationApprover(operator) {
733
732
  }
734
733
  }
735
734
 
736
- // Unlike a read, a failed lookup refuses the decision instead of treating the doctor as not approving.
737
- async function requireApprover(operator) {
738
- let approver;
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;
739
739
  try {
740
- approver = await getEscalationApprover(operator);
740
+ approvers = await listEscalationApprovers();
741
741
  } catch (error) {
742
- logger.error('[EscalationReview] Approver lookup failed; the plan was not recorded', { error: redactedError(error) });
742
+ logger.error('[EscalationReview] The approving doctors could not be listed; the plan was not recorded', { error: redactedError(error) });
743
743
  throw httpError(503, 'The approving doctors could not be read; try again');
744
744
  }
745
- if (!approver) throw httpError(403, PRESCRIBER_ONLY);
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
+ }
746
760
  }
747
761
 
748
762
  async function getEscalationReviewById({ id, requestedBy } = {}) {
@@ -771,6 +785,7 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
771
785
  isApprover,
772
786
  responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()),
773
787
  proposedPlan: draft ? { draftNumber: review.drafts.length, ...proposedPlanOf(draft) } : null,
788
+ prescribers: draft ? await prescribersForRead() : [],
774
789
  };
775
790
  }
776
791
 
@@ -1818,6 +1833,7 @@ async function recordDoctorDecision({
1818
1833
  finalText = null,
1819
1834
  plan: requestedPlan = null,
1820
1835
  instructions = null,
1836
+ prescriberEmail = null,
1821
1837
  comment = null,
1822
1838
  doctorName = null,
1823
1839
  doctorReply = null,
@@ -1853,9 +1869,12 @@ async function recordDoctorDecision({
1853
1869
  if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
1854
1870
  plan = planOf(requestedPlan);
1855
1871
  plan = { ...plan, ...approvedInstructions(plan, instructions) };
1856
- // The team may send home care alone; a note, an edit or a named drug goes out on a doctor's word.
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.
1857
1873
  const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
1858
- if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) await requireApprover(operator);
1874
+ if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) {
1875
+ plan = { ...plan, prescriber: await prescriberOf(prescriberEmail, operator) };
1876
+ decidedBy = plan.prescriber.email;
1877
+ }
1859
1878
  }
1860
1879
 
1861
1880
  const review = await loadReview(reviewId);
@@ -2113,8 +2132,6 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2113
2132
  return syncCase(review, operator, requestId);
2114
2133
  }
2115
2134
 
2116
- const PRESCRIBER_EMAIL_KEY = 'ESCALATION_PRESCRIBER_EMAIL';
2117
-
2118
2135
  // The PDF goes out from the Airtable automations; a failed receta never undoes the message already sent.
2119
2136
  async function issuePrescription(review, plan, prescriberEmail) {
2120
2137
  try {
@@ -2148,11 +2165,11 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
2148
2165
  });
2149
2166
  });
2150
2167
 
2151
- let prescriberEmail = null;
2168
+ const prescriberEmail = needsPrescriber(plan) ? plan.prescriber?.email ?? null : null;
2152
2169
  let delivery;
2153
2170
  let sendThrew;
2154
2171
  try {
2155
- if (needsPrescriber(plan)) prescriberEmail = configured(PRESCRIBER_EMAIL_KEY);
2172
+ if (needsPrescriber(plan) && !prescriberEmail) throw httpError(409, 'This plan names no doctor to sign the receta; record the plan again');
2156
2173
  ({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
2157
2174
  claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
2158
2175
  ) ?? {});
@@ -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. A bucket name
205
- // with dots (whatsapp.prod) is presigned path-style, so the bucket is then the first path segment.
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;
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18676",
3
+ "version": "6.4.0-dev.18775",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",