@peopl-health/nexus 6.3.1 → 6.4.0-dev.18675
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,165 @@
|
|
|
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
|
+
|
|
97
|
+
const FOLLOW_UP_LINES = {
|
|
98
|
+
check_in: (hours) => `Seguimiento: le escribiremos en unas ${hours} horas para ver cómo va.`,
|
|
99
|
+
vigilancia: (hours) => `Seguimiento: queda en vigilancia las próximas ${hours} horas; le escribiremos para ver cómo va.`,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// What the assistant is told to pass on; it writes the patient message itself.
|
|
103
|
+
function planInstructions(plan) {
|
|
104
|
+
const lines = ['Transmítele al paciente lo que indicó el médico, con tus palabras: no cambies dosis, frecuencia ni días, y no agregues otros medicamentos ni estudios.'];
|
|
105
|
+
if (plan.items.length) {
|
|
106
|
+
lines.push('Medicamentos:', ...plan.items.map((item) => `- ${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`));
|
|
107
|
+
}
|
|
108
|
+
if (plan.studies.length) {
|
|
109
|
+
lines.push('Estudios:', ...plan.studies.map((study) => `- ${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`));
|
|
110
|
+
}
|
|
111
|
+
if (plan.homeCare.length) lines.push('Cuidados en casa:', ...plan.homeCare.map((entry) => `- ${entry}`));
|
|
112
|
+
if (plan.note) lines.push(`Nota del médico: «${plan.note}».`);
|
|
113
|
+
if (plan.followUp.kind !== 'none') lines.push(FOLLOW_UP_LINES[plan.followUp.kind](plan.followUp.hours));
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The doctor's edit wins; with none, the instructions follow the plan.
|
|
118
|
+
function approvedInstructions(plan, edited) {
|
|
119
|
+
if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
|
|
120
|
+
if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
|
|
121
|
+
const instructions = edited.trim();
|
|
122
|
+
if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
|
|
123
|
+
return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// «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.
|
|
127
|
+
function dosingOf(instructions) {
|
|
128
|
+
const text = oneLineOf(instructions);
|
|
129
|
+
const match = text.match(/^(.*?)\s+(cada [^,;]+?)(?:\s+por\s+(\d+)\s+d[ií]as?)?(?:\s*[,;]\s*(.*))?$/i);
|
|
130
|
+
if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
|
|
131
|
+
const [, dose, frequency, days, rest] = match;
|
|
132
|
+
// A duration said after other directions («…, y tragar, por 7 días») is still the days.
|
|
133
|
+
const lateDays = !days && rest?.match(/(?:^|[,;]\s*|\s)por\s+(\d+)\s+d[ií]as?\s*$/i);
|
|
134
|
+
return {
|
|
135
|
+
dose: ROUTE_ONLY.test(dose) ? null : dose,
|
|
136
|
+
frequency,
|
|
137
|
+
days: Number(days ?? lateDays?.[1]) || null,
|
|
138
|
+
rest: (lateDays ? rest.slice(0, lateDays.index).replace(/[,;\s]+$/, '') : rest) || null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
|
|
143
|
+
// What the formulary adds after the dosing («además, valoración por oftalmología») goes to the note.
|
|
144
|
+
function proposedPlanOf(draft) {
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const rests = [];
|
|
147
|
+
const items = (draft?.symptoms ?? [])
|
|
148
|
+
.filter((entry) => entry?.proposal?.confirmed)
|
|
149
|
+
.flatMap((entry) => (entry.proposal.items ?? []).map((item) => ({ item, formularyKey: entry.proposal.formularyKey })))
|
|
150
|
+
.filter(({ item }) => !seen.has(oneLineOf(item.drug).toLowerCase()) && seen.add(oneLineOf(item.drug).toLowerCase()))
|
|
151
|
+
.map(({ item, formularyKey }) => {
|
|
152
|
+
const { rest, ...dosing } = dosingOf(item.instructions);
|
|
153
|
+
if (rest) rests.push(`${item.drug}: ${rest}`);
|
|
154
|
+
return { drug: item.drug, strength: item.strength ?? null, ...dosing, position: item.position ?? null, reason: null, formularyKey };
|
|
155
|
+
});
|
|
156
|
+
return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
approvedInstructions,
|
|
161
|
+
needsPrescriber,
|
|
162
|
+
planInstructions,
|
|
163
|
+
planOf,
|
|
164
|
+
proposedPlanOf,
|
|
165
|
+
};
|
|
@@ -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,
|
|
@@ -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 },
|
|
@@ -381,6 +410,7 @@ module.exports = {
|
|
|
381
410
|
DOCTOR_DECISIONS,
|
|
382
411
|
HANDOFF_MODES,
|
|
383
412
|
INTERRUPTED_STATUSES,
|
|
413
|
+
PLAN_FOLLOW_UPS,
|
|
384
414
|
FOLLOWUP_STATUSES,
|
|
385
415
|
RETURN_REASONS,
|
|
386
416
|
URGENCY_LEVELS,
|
package/lib/routes/index.js
CHANGED
|
@@ -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,
|
|
@@ -33,6 +33,7 @@ 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 { approvedInstructions, needsPrescriber, planInstructions, planOf, proposedPlanOf } = require('../clinical/helpers/doctorPlanHelper');
|
|
36
37
|
const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
|
|
37
38
|
const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
|
|
38
39
|
const {
|
|
@@ -104,7 +105,8 @@ const RECLASSIFIABLE_STATUSES = [...DOCUMENTING_STATUSES, 'awaiting_doctor'];
|
|
|
104
105
|
const SAME_URGENCY = 'The review is already at that urgency';
|
|
105
106
|
const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
|
|
106
107
|
const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
|
|
107
|
-
const APPROVALS = ['approved', 'edited_approved'];
|
|
108
|
+
const APPROVALS = ['approved', 'edited_approved', 'plan_approved'];
|
|
109
|
+
const PRESCRIBER_ONLY = 'Only an approving doctor can indicate drugs or studies, or edit the instructions to the assistant';
|
|
108
110
|
const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
|
|
109
111
|
const STALE_HANDOFF = 'New information reached the review after the draft the doctor received; it went back to documenting for a new draft';
|
|
110
112
|
const DRAFT_CHANGED = 'A newer draft was written after the one you saw; read it before deciding';
|
|
@@ -145,7 +147,7 @@ const configured = (key) => {
|
|
|
145
147
|
return value;
|
|
146
148
|
};
|
|
147
149
|
|
|
148
|
-
const STATUS_AFTER = { edited_approved: 'approved', monitor: 'monitoring' };
|
|
150
|
+
const STATUS_AFTER = { edited_approved: 'approved', plan_approved: 'approved', monitor: 'monitoring' };
|
|
149
151
|
const statusAfter = (decision) => STATUS_AFTER[decision] ?? decision;
|
|
150
152
|
|
|
151
153
|
const redactedError = (error) => redactDirectIdentifiers(String(error?.message || ''));
|
|
@@ -728,6 +730,18 @@ async function isEscalationApprover(operator) {
|
|
|
728
730
|
}
|
|
729
731
|
}
|
|
730
732
|
|
|
733
|
+
// Unlike a read, a failed lookup refuses the decision instead of treating the doctor as not approving.
|
|
734
|
+
async function requireApprover(operator) {
|
|
735
|
+
let approver;
|
|
736
|
+
try {
|
|
737
|
+
approver = await getEscalationApprover(operator);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
logger.error('[EscalationReview] Approver lookup failed; the plan was not recorded', { error: redactedError(error) });
|
|
740
|
+
throw httpError(503, 'The approving doctors could not be read; try again');
|
|
741
|
+
}
|
|
742
|
+
if (!approver) throw httpError(403, PRESCRIBER_ONLY);
|
|
743
|
+
}
|
|
744
|
+
|
|
731
745
|
async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
732
746
|
const reviewId = reviewIdOf(id);
|
|
733
747
|
const operator = requiredText(requestedBy, 'requestedBy');
|
|
@@ -748,7 +762,19 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
|
748
762
|
.updateOne({ _id: review._id }, { $max: { approverViewedAt: readAt } }, { timestamps: false })
|
|
749
763
|
.catch(warnUnmarked);
|
|
750
764
|
}
|
|
751
|
-
|
|
765
|
+
const draft = DECIDABLE_STATUSES.includes(review.status) ? review.drafts?.at(-1) : null;
|
|
766
|
+
return {
|
|
767
|
+
review,
|
|
768
|
+
isApprover,
|
|
769
|
+
responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()),
|
|
770
|
+
proposedPlan: draft ? { draftNumber: review.drafts.length, ...proposedPlanOf(draft) } : null,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// The instructions a plan would give the assistant, for the doctor to read before approving.
|
|
775
|
+
function previewPlanInstructions({ id, plan } = {}) {
|
|
776
|
+
reviewIdOf(id);
|
|
777
|
+
return planInstructions(planOf(plan));
|
|
752
778
|
}
|
|
753
779
|
|
|
754
780
|
async function getReviewTimeline({ id } = {}) {
|
|
@@ -1787,6 +1813,8 @@ async function recordDoctorDecision({
|
|
|
1787
1813
|
source,
|
|
1788
1814
|
decision,
|
|
1789
1815
|
finalText = null,
|
|
1816
|
+
plan: requestedPlan = null,
|
|
1817
|
+
instructions = null,
|
|
1790
1818
|
comment = null,
|
|
1791
1819
|
doctorName = null,
|
|
1792
1820
|
doctorReply = null,
|
|
@@ -1815,6 +1843,17 @@ async function recordDoctorDecision({
|
|
|
1815
1843
|
if (!RETURN_REASONS.includes(reason)) throw httpError(400, `reason must be one of: ${RETURN_REASONS.join(', ')}`);
|
|
1816
1844
|
returnReason = reason;
|
|
1817
1845
|
}
|
|
1846
|
+
let plan = null;
|
|
1847
|
+
if (decision === 'plan_approved') {
|
|
1848
|
+
if (source !== 'platform') throw httpError(400, 'A plan is approved by the doctor on the platform');
|
|
1849
|
+
// The plan names the draft it started from, so a newer draft is refused instead of decided unseen.
|
|
1850
|
+
if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
|
|
1851
|
+
plan = planOf(requestedPlan);
|
|
1852
|
+
plan = { ...plan, ...approvedInstructions(plan, instructions) };
|
|
1853
|
+
// The team may send home care alone; a note, an edit or a named drug goes out on a doctor's word.
|
|
1854
|
+
const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
|
|
1855
|
+
if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) await requireApprover(operator);
|
|
1856
|
+
}
|
|
1818
1857
|
|
|
1819
1858
|
const review = await loadReview(reviewId);
|
|
1820
1859
|
const isDraftless = DRAFTLESS_DECISIONS.includes(decision);
|
|
@@ -1858,6 +1897,7 @@ async function recordDoctorDecision({
|
|
|
1858
1897
|
source,
|
|
1859
1898
|
draftNumber,
|
|
1860
1899
|
finalText: approvedText,
|
|
1900
|
+
...(plan && { plan }),
|
|
1861
1901
|
comment: optionalText(comment),
|
|
1862
1902
|
doctorReply: reply,
|
|
1863
1903
|
...(returnReason && { returnReason }),
|
|
@@ -2076,6 +2116,7 @@ const EPISODE_SOURCE = 'doctor';
|
|
|
2076
2116
|
const DECISION_WORDS = {
|
|
2077
2117
|
approved: 'el médico aprobó las indicaciones',
|
|
2078
2118
|
edited_approved: 'el médico aprobó las indicaciones con cambios',
|
|
2119
|
+
plan_approved: 'el médico dio las indicaciones',
|
|
2079
2120
|
needs_intervention: 'el médico pidió valoración presencial',
|
|
2080
2121
|
returned: 'el médico devolvió el caso a navegación',
|
|
2081
2122
|
};
|
|
@@ -2259,6 +2300,7 @@ module.exports = {
|
|
|
2259
2300
|
generateBotDraft,
|
|
2260
2301
|
getEscalationReviewById,
|
|
2261
2302
|
getReviewTimeline,
|
|
2303
|
+
previewPlanInstructions,
|
|
2262
2304
|
handOffToDoctor,
|
|
2263
2305
|
listEscalationReviews,
|
|
2264
2306
|
openEscalationReview,
|