@peopl-health/nexus 6.3.1 → 6.4.0-dev.18676

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.
@@ -0,0 +1,193 @@
1
+ const { httpError } = require('../../utils/httpErrorUtils');
2
+ const { oneLineOf } = require('../../utils/coercionUtils');
3
+ const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
4
+
5
+ const DEFAULT_FOLLOW_UP_HOURS = { check_in: 24, vigilancia: 48 };
6
+ const FIRST_LINE = '1L';
7
+ const MAX_ENTRIES = 10;
8
+ const MAX_FIELD_LENGTH = 300;
9
+ const MAX_NOTE_LENGTH = 1000;
10
+ const MAX_INSTRUCTIONS_LENGTH = 4000;
11
+ const MAX_DAYS = 90;
12
+ const MAX_FOLLOW_UP_HOURS = 168;
13
+ // A route is not a dose: the doctor still says how many tablets.
14
+ const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
15
+
16
+ const textOf = (value, name, max = MAX_FIELD_LENGTH) => {
17
+ if (value != null && typeof value !== 'string') throw httpError(400, `${name} must be text`);
18
+ const text = oneLineOf(value);
19
+ if (text.length > max) throw httpError(400, `${name} exceeds ${max} characters`);
20
+ return text || null;
21
+ };
22
+
23
+ const neededText = (value, name) => {
24
+ const text = textOf(value, name);
25
+ if (!text) throw httpError(400, `${name} is required`);
26
+ return text;
27
+ };
28
+
29
+ const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
30
+
31
+ // Only a number or a string of digits: `true`, `[5]` and `'1e1'` are not days.
32
+ const wholeNumber = (value, name, max) => {
33
+ const number = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim())) ? Number(value) : NaN;
34
+ if (!Number.isInteger(number) || number < 1 || number > max) throw httpError(400, `${name} must be a whole number from 1 to ${max}`);
35
+ return number;
36
+ };
37
+
38
+ const listOf = (value, name) => {
39
+ if (value == null) return [];
40
+ if (!Array.isArray(value)) throw httpError(400, `${name} must be a list`);
41
+ if (value.length > MAX_ENTRIES) throw httpError(400, `${name} allows at most ${MAX_ENTRIES} entries`);
42
+ return value;
43
+ };
44
+
45
+ function itemOf(item, index) {
46
+ const name = `plan.items[${index}]`;
47
+ const position = textOf(item?.position, `${name}.position`);
48
+ const reason = textOf(item?.reason, `${name}.reason`);
49
+ // An alternative to the first line has to say why the first line does not fit.
50
+ if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
51
+ return {
52
+ drug: neededText(item?.drug, `${name}.drug`),
53
+ strength: textOf(item?.strength, `${name}.strength`),
54
+ dose: neededText(item?.dose, `${name}.dose`),
55
+ frequency: neededText(item?.frequency, `${name}.frequency`),
56
+ days: wholeNumber(item?.days, `${name}.days`, MAX_DAYS),
57
+ position,
58
+ reason,
59
+ formularyKey: textOf(item?.formularyKey, `${name}.formularyKey`),
60
+ };
61
+ }
62
+
63
+ function followUpOf(value) {
64
+ if (value != null && !isPlainObject(value)) throw httpError(400, 'plan.followUp must be an object with a kind');
65
+ const kind = value?.kind ?? 'none';
66
+ if (!PLAN_FOLLOW_UPS.includes(kind)) throw httpError(400, `plan.followUp.kind must be one of: ${PLAN_FOLLOW_UPS.join(', ')}`);
67
+ if (kind === 'none') return { kind, hours: null };
68
+ return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
69
+ }
70
+
71
+ // The plan as the doctor approved it, refused whole when any line is incomplete.
72
+ function planOf(input) {
73
+ if (!isPlainObject(input)) throw httpError(400, 'plan is required');
74
+ const plan = {
75
+ items: listOf(input.items, 'plan.items').map((item, index) => {
76
+ if (!isPlainObject(item)) throw httpError(400, `plan.items[${index}] must be an object`);
77
+ return itemOf(item, index);
78
+ }),
79
+ studies: listOf(input.studies, 'plan.studies').map((study, index) => ({
80
+ name: neededText(study?.name, `plan.studies[${index}].name`),
81
+ instructions: textOf(study?.instructions, `plan.studies[${index}].instructions`),
82
+ })),
83
+ homeCare: listOf(input.homeCare, 'plan.homeCare').map((entry, index) => neededText(entry, `plan.homeCare[${index}]`)),
84
+ note: textOf(input.note, 'plan.note', MAX_NOTE_LENGTH),
85
+ followUp: followUpOf(input.followUp),
86
+ };
87
+ if (!plan.items.length && !plan.studies.length && !plan.homeCare.length && !plan.note) {
88
+ throw httpError(400, 'plan needs a drug, a study, a home care measure or a note');
89
+ }
90
+ return plan;
91
+ }
92
+
93
+ const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
94
+
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.';
99
+
100
+ const FOLLOW_UP_LINES = {
101
+ check_in: (hours) => `Seguimiento: le escribiremos en unas ${hours} horas para ver cómo va.`,
102
+ vigilancia: (hours) => `Seguimiento: queda en vigilancia las próximas ${hours} horas; le escribiremos para ver cómo va.`,
103
+ };
104
+
105
+ // What the assistant is told to pass on; it writes the patient message itself.
106
+ function planInstructions(plan) {
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.'];
108
+ if (plan.items.length) {
109
+ lines.push('Medicamentos:', ...plan.items.map((item) => `- ${itemLine(item)}`));
110
+ }
111
+ if (plan.studies.length) {
112
+ lines.push('Estudios:', ...plan.studies.map((study) => `- ${studyLine(study)}`));
113
+ }
114
+ if (plan.homeCare.length) lines.push('Cuidados en casa:', ...plan.homeCare.map((entry) => `- ${entry}`));
115
+ if (plan.note) lines.push(`Nota del médico: «${plan.note}».`);
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));
139
+ return lines.join('\n');
140
+ }
141
+
142
+ // The doctor's edit wins; with none, the instructions follow the plan.
143
+ function approvedInstructions(plan, edited) {
144
+ if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
145
+ if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
146
+ const instructions = edited.trim();
147
+ if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
148
+ return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
149
+ }
150
+
151
+ // «1 tableta cada 24 horas por 5 días; además, …» → dose, frequency, days and the rest; what does not match is left for the doctor.
152
+ function dosingOf(instructions) {
153
+ const text = oneLineOf(instructions);
154
+ const match = text.match(/^(.*?)\s+(cada [^,;]+?)(?:\s+por\s+(\d+)\s+d[ií]as?)?(?:\s*[,;]\s*(.*))?$/i);
155
+ if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
156
+ const [, dose, frequency, days, rest] = match;
157
+ // A duration said after other directions («…, y tragar, por 7 días») is still the days.
158
+ const lateDays = !days && rest?.match(/(?:^|[,;]\s*|\s)por\s+(\d+)\s+d[ií]as?\s*$/i);
159
+ return {
160
+ dose: ROUTE_ONLY.test(dose) ? null : dose,
161
+ frequency,
162
+ days: Number(days ?? lateDays?.[1]) || null,
163
+ rest: (lateDays ? rest.slice(0, lateDays.index).replace(/[,;\s]+$/, '') : rest) || null,
164
+ };
165
+ }
166
+
167
+ // The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
168
+ // What the formulary adds after the dosing («además, valoración por oftalmología») goes to the note.
169
+ function proposedPlanOf(draft) {
170
+ const seen = new Set();
171
+ const rests = [];
172
+ const items = (draft?.symptoms ?? [])
173
+ .filter((entry) => entry?.proposal?.confirmed)
174
+ .flatMap((entry) => (entry.proposal.items ?? []).map((item) => ({ item, formularyKey: entry.proposal.formularyKey })))
175
+ .filter(({ item }) => !seen.has(oneLineOf(item.drug).toLowerCase()) && seen.add(oneLineOf(item.drug).toLowerCase()))
176
+ .map(({ item, formularyKey }) => {
177
+ const { rest, ...dosing } = dosingOf(item.instructions);
178
+ if (rest) rests.push(`${item.drug}: ${rest}`);
179
+ return { drug: item.drug, strength: item.strength ?? null, ...dosing, position: item.position ?? null, reason: null, formularyKey };
180
+ });
181
+ return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
182
+ }
183
+
184
+ module.exports = {
185
+ approvedInstructions,
186
+ assistantInstructionsOf,
187
+ needsPrescriber,
188
+ planInstructions,
189
+ planOf,
190
+ planSummary,
191
+ prescriptionText,
192
+ proposedPlanOf,
193
+ };
@@ -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: {
@@ -11,6 +11,7 @@ const {
11
11
  handOffToDoctor,
12
12
  listEscalationReviews,
13
13
  openEscalationReview,
14
+ previewPlanInstructions,
14
15
  reclassifyUrgency,
15
16
  recordDoctorDecision,
16
17
  requestBotDraft,
@@ -53,13 +54,22 @@ const openEscalationReviewController = async (req, res) => {
53
54
 
54
55
  const getEscalationReviewController = async (req, res) => {
55
56
  try {
56
- const { review, isApprover, responseDeadline } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
57
- res.status(200).json({ success: true, review: withCaseStatus(review), isApprover, responseDeadline });
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 });
58
59
  } catch (error) {
59
60
  respondWithError(res, error, 'reading escalation review', { reviewId: req.params?.id });
60
61
  }
61
62
  };
62
63
 
64
+ const previewEscalationReviewPlanController = async (req, res) => {
65
+ try {
66
+ const instructions = previewPlanInstructions({ id: req.params.id, plan: req.body?.plan });
67
+ res.status(200).json({ success: true, instructions });
68
+ } catch (error) {
69
+ respondWithError(res, error, 'previewing the plan instructions', { reviewId: req.params?.id });
70
+ }
71
+ };
72
+
63
73
  const getReviewTimelineController = async (req, res) => {
64
74
  try {
65
75
  const timeline = await getReviewTimeline({ id: req.params.id });
@@ -175,6 +185,8 @@ const recordEscalationReviewDecisionController = async (req, res) => {
175
185
  source,
176
186
  decision,
177
187
  finalText = null,
188
+ plan = null,
189
+ instructions = null,
178
190
  comment = null,
179
191
  doctorName = null,
180
192
  doctorReply = null,
@@ -188,6 +200,8 @@ const recordEscalationReviewDecisionController = async (req, res) => {
188
200
  source,
189
201
  decision,
190
202
  finalText,
203
+ plan,
204
+ instructions,
191
205
  comment,
192
206
  doctorName,
193
207
  doctorReply,
@@ -284,6 +298,7 @@ module.exports = {
284
298
  closeEscalationReviewController,
285
299
  getEscalationReviewController,
286
300
  getReviewTimelineController,
301
+ previewEscalationReviewPlanController,
287
302
  requestEscalationReviewDraftController,
288
303
  handOffEscalationReviewController,
289
304
  listEscalationReviewsController,
@@ -1,17 +1,11 @@
1
- const { Monitoreo_ID } = require('../config/airtableConfig');
2
-
3
1
  const { logger } = require('../utils/logger');
4
2
 
5
- const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
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
- code = ensureWhatsAppFormat(code);
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: record?.id || null });
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' });
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<void>;
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>;
@@ -12,7 +12,8 @@ const HANDOFF_MODES = ['sent_whatsapp', 'copied'];
12
12
  const INTERRUPTED_STATUSES = ['handing_off', 'sending'];
13
13
  const CANCELLABLE_AFTER_HANDOFF_STATUSES = ['awaiting_doctor', 'approved', 'returned', 'indications_queued'];
14
14
  const CANCELLABLE_STATUSES = ['open', 'documenting', 'draft_ready', ...CANCELLABLE_AFTER_HANDOFF_STATUSES];
15
- const DOCTOR_DECISIONS = ['approved', 'edited_approved', 'needs_intervention', 'returned', 'monitor', 'direct_chat'];
15
+ const DOCTOR_DECISIONS = ['approved', 'edited_approved', 'plan_approved', 'needs_intervention', 'returned', 'monitor', 'direct_chat'];
16
+ const PLAN_FOLLOW_UPS = ['none', 'check_in', 'vigilancia'];
16
17
  const DECISION_SOURCES = ['platform', 'registered_by_navigator', 'chat'];
17
18
  const RETURN_REASONS = ['llamar', 'falta_dato', 'no_requiere'];
18
19
 
@@ -193,11 +194,39 @@ const doctorHandoffSchema = new mongoose.Schema({
193
194
  sentBy: { type: String, required: true },
194
195
  }, { timestamps: { createdAt: true, updatedAt: false } });
195
196
 
197
+ const planItemSchema = new mongoose.Schema({
198
+ drug: { type: String, required: true },
199
+ strength: { type: String, default: null },
200
+ dose: { type: String, required: true },
201
+ frequency: { type: String, required: true },
202
+ days: { type: Number, required: true },
203
+ position: { type: String, default: null },
204
+ reason: { type: String, default: null },
205
+ formularyKey: { type: String, default: null },
206
+ }, { _id: false });
207
+
208
+ const planSchema = new mongoose.Schema({
209
+ items: { type: [planItemSchema], default: [] },
210
+ studies: {
211
+ type: [new mongoose.Schema({ name: { type: String, required: true }, instructions: { type: String, default: null } }, { _id: false })],
212
+ default: [],
213
+ },
214
+ homeCare: { type: [String], default: [] },
215
+ note: { type: String, default: null },
216
+ followUp: {
217
+ type: new mongoose.Schema({ kind: { type: String, enum: PLAN_FOLLOW_UPS, required: true }, hours: { type: Number, default: null } }, { _id: false }),
218
+ required: true,
219
+ },
220
+ instructions: { type: String, required: true },
221
+ instructionsEdited: { type: Boolean, default: false },
222
+ }, { _id: false });
223
+
196
224
  const decisionSchema = new mongoose.Schema({
197
225
  decision: { type: String, enum: DOCTOR_DECISIONS, required: true },
198
226
  source: { type: String, enum: DECISION_SOURCES, required: true },
199
227
  draftNumber: { type: Number, required: true },
200
228
  finalText: { type: String, default: null },
229
+ plan: { type: planSchema, default: undefined },
201
230
  comment: { type: String, default: null },
202
231
  doctorReply: { type: String, default: null },
203
232
  returnReason: { type: String, enum: [...RETURN_REASONS, null], default: null },
@@ -210,7 +239,8 @@ const patientDeliverySchema = new mongoose.Schema({
210
239
  decisionNumber: { type: Number, required: true },
211
240
  messageId: { type: String, default: null },
212
241
  status: { type: String, default: null },
213
- transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template'], required: true },
242
+ transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template', 'assistant'], required: true },
243
+ prescriptionRecordId: { type: String, default: null },
214
244
  deferred: { type: Boolean, default: false },
215
245
  confirmedBy: { type: String, default: null },
216
246
  confirmedAt: { type: Date, default: null },
@@ -381,6 +411,7 @@ module.exports = {
381
411
  DOCTOR_DECISIONS,
382
412
  HANDOFF_MODES,
383
413
  INTERRUPTED_STATUSES,
414
+ PLAN_FOLLOW_UPS,
384
415
  FOLLOWUP_STATUSES,
385
416
  RETURN_REASONS,
386
417
  URGENCY_LEVELS,
@@ -138,6 +138,7 @@ const escalationReviewRouteDefinitions = {
138
138
  'POST /:id/note-facts': 'confirmEscalationReviewNoteFactsController',
139
139
  'POST /:id/drafts': 'addEscalationReviewDraftController',
140
140
  'POST /:id/doctor-handoffs': 'handOffEscalationReviewController',
141
+ 'POST /:id/plan-instructions': 'previewEscalationReviewPlanController',
141
142
  'POST /:id/decisions': 'recordEscalationReviewDecisionController',
142
143
  'POST /:id/patient-deliveries': 'sendEscalationReviewIndicationsController',
143
144
  'POST /:id/close': 'closeEscalationReviewController',
@@ -314,6 +315,7 @@ const builtInControllers = {
314
315
  listReviewBoardController: escalationReviewController.listReviewBoardController,
315
316
  getEscalationReviewController: escalationReviewController.getEscalationReviewController,
316
317
  getReviewTimelineController: escalationReviewController.getReviewTimelineController,
318
+ previewEscalationReviewPlanController: escalationReviewController.previewEscalationReviewPlanController,
317
319
  requestEscalationReviewDraftController: escalationReviewController.requestEscalationReviewDraftController,
318
320
  setEscalationReviewCasesController: escalationReviewController.setEscalationReviewCasesController,
319
321
  addEscalationReviewCallLogController: escalationReviewController.addEscalationReviewCallLogController,
@@ -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
- throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount);
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,6 +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 {
37
+ approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, proposedPlanOf,
38
+ } = require('../clinical/helpers/doctorPlanHelper');
36
39
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
37
40
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
38
41
  const {
@@ -52,6 +55,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
52
55
  const { MAX_NOTE_LENGTH, openCaseIds, readPatientCases, updatePatientTask } = require('./patientTaskService');
53
56
  const { countMessages } = require('./messageService');
54
57
  const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
58
+ const { createPrescription } = require('./prescriptionService');
55
59
  const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
56
60
  const { effectiveUrgencyOf, levelsOf, patientTimerMinutes, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
57
61
  const { caseTaskLive, scheduleCaseSync } = require('./escalationCaseTaskService');
@@ -104,7 +108,8 @@ const RECLASSIFIABLE_STATUSES = [...DOCUMENTING_STATUSES, 'awaiting_doctor'];
104
108
  const SAME_URGENCY = 'The review is already at that urgency';
105
109
  const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
106
110
  const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
107
- const APPROVALS = ['approved', 'edited_approved'];
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';
108
113
  const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
109
114
  const STALE_HANDOFF = 'New information reached the review after the draft the doctor received; it went back to documenting for a new draft';
110
115
  const DRAFT_CHANGED = 'A newer draft was written after the one you saw; read it before deciding';
@@ -145,7 +150,7 @@ const configured = (key) => {
145
150
  return value;
146
151
  };
147
152
 
148
- const STATUS_AFTER = { edited_approved: 'approved', monitor: 'monitoring' };
153
+ const STATUS_AFTER = { edited_approved: 'approved', plan_approved: 'approved', monitor: 'monitoring' };
149
154
  const statusAfter = (decision) => STATUS_AFTER[decision] ?? decision;
150
155
 
151
156
  const redactedError = (error) => redactDirectIdentifiers(String(error?.message || ''));
@@ -728,6 +733,18 @@ async function isEscalationApprover(operator) {
728
733
  }
729
734
  }
730
735
 
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;
739
+ try {
740
+ approver = await getEscalationApprover(operator);
741
+ } catch (error) {
742
+ logger.error('[EscalationReview] Approver lookup failed; the plan was not recorded', { error: redactedError(error) });
743
+ throw httpError(503, 'The approving doctors could not be read; try again');
744
+ }
745
+ if (!approver) throw httpError(403, PRESCRIBER_ONLY);
746
+ }
747
+
731
748
  async function getEscalationReviewById({ id, requestedBy } = {}) {
732
749
  const reviewId = reviewIdOf(id);
733
750
  const operator = requiredText(requestedBy, 'requestedBy');
@@ -748,7 +765,19 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
748
765
  .updateOne({ _id: review._id }, { $max: { approverViewedAt: readAt } }, { timestamps: false })
749
766
  .catch(warnUnmarked);
750
767
  }
751
- return { review, isApprover, responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()) };
768
+ const draft = DECIDABLE_STATUSES.includes(review.status) ? review.drafts?.at(-1) : null;
769
+ return {
770
+ review,
771
+ isApprover,
772
+ responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()),
773
+ proposedPlan: draft ? { draftNumber: review.drafts.length, ...proposedPlanOf(draft) } : null,
774
+ };
775
+ }
776
+
777
+ // The instructions a plan would give the assistant, for the doctor to read before approving.
778
+ function previewPlanInstructions({ id, plan } = {}) {
779
+ reviewIdOf(id);
780
+ return planInstructions(planOf(plan));
752
781
  }
753
782
 
754
783
  async function getReviewTimeline({ id } = {}) {
@@ -1787,6 +1816,8 @@ async function recordDoctorDecision({
1787
1816
  source,
1788
1817
  decision,
1789
1818
  finalText = null,
1819
+ plan: requestedPlan = null,
1820
+ instructions = null,
1790
1821
  comment = null,
1791
1822
  doctorName = null,
1792
1823
  doctorReply = null,
@@ -1815,6 +1846,17 @@ async function recordDoctorDecision({
1815
1846
  if (!RETURN_REASONS.includes(reason)) throw httpError(400, `reason must be one of: ${RETURN_REASONS.join(', ')}`);
1816
1847
  returnReason = reason;
1817
1848
  }
1849
+ let plan = null;
1850
+ if (decision === 'plan_approved') {
1851
+ if (source !== 'platform') throw httpError(400, 'A plan is approved by the doctor on the platform');
1852
+ // The plan names the draft it started from, so a newer draft is refused instead of decided unseen.
1853
+ if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
1854
+ plan = planOf(requestedPlan);
1855
+ 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.
1857
+ const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
1858
+ if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) await requireApprover(operator);
1859
+ }
1818
1860
 
1819
1861
  const review = await loadReview(reviewId);
1820
1862
  const isDraftless = DRAFTLESS_DECISIONS.includes(decision);
@@ -1858,6 +1900,7 @@ async function recordDoctorDecision({
1858
1900
  source,
1859
1901
  draftNumber,
1860
1902
  finalText: approvedText,
1903
+ ...(plan && { plan }),
1861
1904
  comment: optionalText(comment),
1862
1905
  doctorReply: reply,
1863
1906
  ...(returnReason && { returnReason }),
@@ -1993,10 +2036,10 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
1993
2036
  const claim = newOperationClaim();
1994
2037
  const claimed = await updateReview(reviewId, SENDABLE_STATUSES, { $set: { status: 'sending', operationClaim: claim } });
1995
2038
  const decisionNumber = claimed.decisions.length;
1996
- const { finalText, decision } = claimed.decisions[decisionNumber - 1];
2039
+ const { finalText, decision, plan } = claimed.decisions[decisionNumber - 1];
1997
2040
  const claimedFrom = statusAfter(decision);
1998
2041
 
1999
- if (!finalText) {
2042
+ if (!finalText && !plan) {
2000
2043
  await releaseReview(reviewId, 'sending', claimedFrom, claim).catch((releaseError) => {
2001
2044
  logger.error('[EscalationReview] The claim was not released after a decision with no text', {
2002
2045
  escalationReviewId: String(claimed._id), error: releaseError.message,
@@ -2014,6 +2057,7 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2014
2057
  });
2015
2058
  throw httpError(409, 'The agent characterized the case after this decision; it went back to documenting for a new draft and decision');
2016
2059
  }
2060
+ if (plan) return sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId });
2017
2061
 
2018
2062
  // Built outside the try: composing reads Mongo and Airtable, and failing there never sent.
2019
2063
  let message;
@@ -2069,6 +2113,100 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2069
2113
  return syncCase(review, operator, requestId);
2070
2114
  }
2071
2115
 
2116
+ const PRESCRIBER_EMAIL_KEY = 'ESCALATION_PRESCRIBER_EMAIL';
2117
+
2118
+ // The PDF goes out from the Airtable automations; a failed receta never undoes the message already sent.
2119
+ async function issuePrescription(review, plan, prescriberEmail) {
2120
+ try {
2121
+ return await createPrescription({
2122
+ code: review.patientCode, prescription: prescriptionText(plan), authorEmail: prescriberEmail, creationSource: 'patient case',
2123
+ });
2124
+ } catch (error) {
2125
+ logger.error('[EscalationReview] The prescription was not created after the plan was sent', {
2126
+ escalationReviewId: String(review._id), error: redactedError(error),
2127
+ });
2128
+ return null;
2129
+ }
2130
+ }
2131
+
2132
+ // How the assistant's reply left: sent, queued for the patient's reply window, not sent, or unknown.
2133
+ function replyOutcome(delivery, sendThrew) {
2134
+ if (sendThrew) return 'unknown';
2135
+ if (!delivery) return 'not_sent';
2136
+ if (delivery.success === true && delivery.deferred === true) return 'queued';
2137
+ if (delivery.success === true && typeof delivery.messageId === 'string' && delivery.messageId) return 'sent';
2138
+ if (delivery.success === false && delivery.deferred !== true && delivery.duplicate !== true) return 'not_sent';
2139
+ return 'unknown';
2140
+ }
2141
+
2142
+ // The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen.
2143
+ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
2144
+ const reviewId = claimed._id;
2145
+ const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
2146
+ logger.error('[EscalationReview] The claim was not released after a plan the assistant did not send', {
2147
+ escalationReviewId: String(reviewId), error: releaseError.message,
2148
+ });
2149
+ });
2150
+
2151
+ let prescriberEmail = null;
2152
+ let delivery;
2153
+ let sendThrew;
2154
+ try {
2155
+ if (needsPrescriber(plan)) prescriberEmail = configured(PRESCRIBER_EMAIL_KEY);
2156
+ ({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
2157
+ claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
2158
+ ) ?? {});
2159
+ } catch (error) {
2160
+ // A turn refused by a deploy's drain never ran; any other failure after the instructions were stored may have answered.
2161
+ if (error?.reasonCode === 'MESSAGE_REPLY_FAILED' && !(error.cause instanceof DrainingError)) {
2162
+ logger.error('[EscalationReview] The assistant turn failed after the instructions were stored; leaving the review in sending', {
2163
+ escalationReviewId: String(reviewId),
2164
+ });
2165
+ throw httpError(502, 'The assistant turn failed after the instructions were stored; check the chat before sending again');
2166
+ }
2167
+ await release();
2168
+ if (error?.cause instanceof DrainingError) throw httpError(503, 'The server is restarting; send the plan again in a minute');
2169
+ throw error;
2170
+ }
2171
+
2172
+ const outcome = replyOutcome(delivery, sendThrew);
2173
+ if (outcome === 'not_sent') {
2174
+ await release();
2175
+ throw httpError(502, 'The assistant did not send a message to the patient; check the chat and send the plan again');
2176
+ }
2177
+ if (outcome === 'unknown') {
2178
+ logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
2179
+ throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
2180
+ }
2181
+
2182
+ // A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
2183
+ const queued = outcome === 'queued';
2184
+ const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
2185
+ const record = {
2186
+ decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
2187
+ ...(prescriptionRecordId && { prescriptionRecordId }),
2188
+ };
2189
+ const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
2190
+ const status = queued ? 'indications_queued' : 'indications_sent';
2191
+ const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
2192
+ const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2193
+ await audit(review, operator, requestId, {
2194
+ action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
2195
+ });
2196
+ // Closing waits for a sent reply and the receta it promised; otherwise the team still has something to do.
2197
+ if (plan.followUp.kind === 'none' && !queued && !prescriptionFailed) {
2198
+ try {
2199
+ return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
2200
+ } catch (error) {
2201
+ logger.error('[EscalationReview] The review was not closed after its plan was sent', {
2202
+ escalationReviewId: String(reviewId), error: redactedError(error),
2203
+ });
2204
+ return syncCase(await loadReview(reviewId), operator, requestId);
2205
+ }
2206
+ }
2207
+ return syncCase(review, operator, requestId);
2208
+ }
2209
+
2072
2210
  const EPISODE_TRUST = 0.7;
2073
2211
  const EPISODE_VARIABLE = 'intercurrent_events_summary';
2074
2212
  const EPISODE_SOURCE = 'doctor';
@@ -2076,6 +2214,7 @@ const EPISODE_SOURCE = 'doctor';
2076
2214
  const DECISION_WORDS = {
2077
2215
  approved: 'el médico aprobó las indicaciones',
2078
2216
  edited_approved: 'el médico aprobó las indicaciones con cambios',
2217
+ plan_approved: 'el médico dio las indicaciones',
2079
2218
  needs_intervention: 'el médico pidió valoración presencial',
2080
2219
  returned: 'el médico devolvió el caso a navegación',
2081
2220
  };
@@ -2259,6 +2398,7 @@ module.exports = {
2259
2398
  generateBotDraft,
2260
2399
  getEscalationReviewById,
2261
2400
  getReviewTimeline,
2401
+ previewPlanInstructions,
2262
2402
  handOffToDoctor,
2263
2403
  listEscalationReviews,
2264
2404
  openEscalationReview,
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.3.1",
3
+ "version": "6.4.0-dev.18676",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",