@peopl-health/nexus 5.71.12 → 5.71.13

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.
@@ -25,6 +25,7 @@ function normalize(raw) {
25
25
  const dated = (value, keys) => (isPlainObject(value) && isDay(value.fecha) && keys.every((k) => k in value) ? value : null);
26
26
  return {
27
27
  ultimaAplicacion: last,
28
+ ultimaCitotoxica: dayOr(raw.ultimaCitotoxica),
28
29
  esquema: list(raw.esquema).filter(isPlainObject).filter((e) => text(e.farmaco)),
29
30
  vigilancia: texts(raw.vigilancia),
30
31
  nadirDias: nadir,
@@ -50,6 +51,10 @@ function parseChemoContext(raw) {
50
51
  return isPlainObject(parsed) && parsed.version === 1 ? normalize(parsed) : null;
51
52
  }
52
53
 
54
+ function daysSinceLastCytotoxic(context, today = mexicoDay(new Date())) {
55
+ return context?.ultimaCitotoxica ? daysBetween(context.ultimaCitotoxica, today) : null;
56
+ }
57
+
53
58
  function chemoStatus(context, today) {
54
59
  const last = context.ultimaAplicacion?.fecha;
55
60
  if (!last) return { active: false, days: null };
@@ -162,4 +167,4 @@ function withChemoContext(clinical, context, today = mexicoDay(new Date())) {
162
167
  }
163
168
  }
164
169
 
165
- module.exports = { parseChemoContext, renderChemoContext, withChemoContext };
170
+ module.exports = { daysSinceLastCytotoxic, parseChemoContext, renderChemoContext, withChemoContext };
@@ -1,5 +1,6 @@
1
1
  const { resolveProfileIdentity } = require('../services/patientProfileService');
2
2
  const { safeParse } = require('../../utils/jsonUtils');
3
+ const { parseChemoContext } = require('./chemoContextHelper');
3
4
 
4
5
  const CONTEXT_FIELD = 'clinical-context-json';
5
6
 
@@ -32,15 +33,28 @@ function richestProfile(records) {
32
33
  return parsed[0].profile;
33
34
  }
34
35
 
35
- async function readClinicalProfile(code) {
36
+ // null: no profile; undefined: unreadable
37
+ async function readResolvedRecord(code) {
36
38
  try {
37
39
  const identity = await resolveProfileIdentity(code);
38
40
  if (identity.kind === 'missing') return null;
39
- if (identity.kind !== 'resolved') return undefined;
40
- return richestProfile([identity.record]);
41
+ return identity.kind === 'resolved' ? identity.record : undefined;
41
42
  } catch {
42
43
  return undefined;
43
44
  }
44
45
  }
45
46
 
46
- module.exports = { readClinicalProfile, richestProfile };
47
+ async function readClinicalProfile(code) {
48
+ const record = await readResolvedRecord(code);
49
+ return record ? richestProfile([record]) : record;
50
+ }
51
+
52
+ // null: no chemo record; undefined: unreadable or unparseable
53
+ async function readRecordedChemo(code) {
54
+ const record = await readResolvedRecord(code);
55
+ if (!record) return record;
56
+ if (!record.quimio_context) return null;
57
+ return parseChemoContext(record.quimio_context) ?? undefined;
58
+ }
59
+
60
+ module.exports = { readClinicalProfile, readRecordedChemo, richestProfile };
@@ -17,6 +17,10 @@ const { URGENCY_RANK } = require('../../models/escalationReviewModel');
17
17
  const { objectArg, safeParse, isPlainObject } = require('../../utils/jsonUtils');
18
18
  const { logger } = require('../../utils/logger');
19
19
 
20
+ const FEVER_RUBRIC_STAT = 'neutropenia febril CON inestabilidad, fiebre ≥38.0 °C en la ventana de quimioterapia descrita en la regla de fiebre,';
21
+
22
+ const FEVER_RUBRIC_ASAP = 'riesgo de infección sin fiebre ≥38.0 °C en esa ventana (p. ej. 37.5–37.9 °C con el paciente bien),';
23
+
20
24
  const FEVER_RULE = 'Fever that is `stat`: ≥38.0 °C within 8 weeks of cytotoxic chemotherapy (or a hematologic cancer, transplant, known neutropenia), any fever within 4 weeks of CAR-T or during bispecific step-up dosing, or sepsis signs with or without fever — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message; unknown date on chemo counts as within the window. Fever 37.5–37.9 °C in that window with the patient well is `asap`: remeasure in 30–60 min. ';
21
25
 
22
26
  const definition = {
@@ -34,7 +38,7 @@ const definition = {
34
38
  'urgent',
35
39
  'routine'
36
40
  ],
37
- 'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: neutropenia febril CON inestabilidad, fiebre ≥38.0 °C en la ventana de quimioterapia descrita en la regla de fiebre, disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; objetivo interno de valoración en horas (no se le promete al paciente), porque la demora puede crear riesgo; el paciente no va a urgencias salvo que aparezca una señal de la red de seguridad. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, riesgo de infección sin fiebre ≥38.0 °C en esa ventana (p. ej. 37.5–37.9 °C con el paciente bien), síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — objetivo interno de revisión en 24 h (no se le promete al paciente), por encima de rutina porque la demora podría importar clínicamente; el paciente puede seguir en casa con una red de seguridad: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
41
+ 'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: ' + FEVER_RUBRIC_STAT + ' disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; objetivo interno de valoración en horas (no se le promete al paciente), porque la demora puede crear riesgo; el paciente no va a urgencias salvo que aparezca una señal de la red de seguridad. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, ' + FEVER_RUBRIC_ASAP + ' síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — objetivo interno de revisión en 24 h (no se le promete al paciente), por encima de rutina porque la demora podría importar clínicamente; el paciente puede seguir en casa con una red de seguridad: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
38
42
  },
39
43
  'patient_check_in': {
40
44
  'anyOf': [
@@ -157,16 +161,9 @@ const MODE_DESCRIPTION = '\n\n**mode:** `new` abre un aviso; `update` actualiza
157
161
 
158
162
  const FEVER_RULE_TIMER = 'Fever within 6 weeks (42 days) of cytotoxic chemotherapy (or with a hematologic cancer, transplant, known neutropenia) — ≥38.0 °C, ≤36.0 °C, feeling feverish or chills with no thermometer, or malaise while on an antipyretic or steroid — is `asap` with `category: febrile_neutropenia`. It is `stat` only with a clear, confirmed red flag (clear shortness of breath, clear confusion, strong rigors, mottled skin, bleeding), or any fever within 4 weeks of CAR-T or during bispecific step-up dosing — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message. 37.5–37.9 °C in that window is `urgent`: remeasure in 30–60 min. ';
159
163
 
160
- const CHEMO_WINDOW_PROPERTY = nullable({
161
- 'type': 'object',
162
- 'description': 'Quimioterapia citotóxica, del expediente o de lo que el paciente confirmó. Requerida y confirmada para `febrile_neutropenia`; pasa `null` si no está confirmada. No la supongas.',
163
- 'properties': {
164
- 'on_cytotoxic_chemo': { 'type': 'boolean' },
165
- 'days_since_last_cycle': nullable({ 'type': 'integer' }),
166
- },
167
- 'required': ['on_cytotoxic_chemo', 'days_since_last_cycle'],
168
- 'additionalProperties': false,
169
- });
164
+ const FEVER_RUBRIC_STAT_TIMER = 'fiebre en la ventana de quimioterapia con una bandera roja confirmada (regla de fiebre),';
165
+
166
+ const FEVER_RUBRIC_ASAP_TIMER = 'fiebre ≥38.0 °C o ≤36.0 °C en la ventana de quimioterapia sin bandera roja (regla de fiebre),';
170
167
 
171
168
  const CLINICAL_ASSESSMENT_PROPERTY = nullable({
172
169
  'type': 'object',
@@ -224,20 +221,14 @@ function liveDefinition() {
224
221
  properties.details.required.push('clinical_assessment');
225
222
  }
226
223
  if (feverTimer) {
227
- live.description = live.description.replace('`recommended_action`).', '`recommended_action`, `chemo_window`).');
228
224
  live.description = live.description.replace(FEVER_RULE, FEVER_RULE_TIMER);
229
- properties.details.properties.chemo_window = CHEMO_WINDOW_PROPERTY;
230
- properties.details.required.push('chemo_window');
225
+ properties.urgency_level.description = properties.urgency_level.description
226
+ .replace(FEVER_RUBRIC_STAT, FEVER_RUBRIC_STAT_TIMER)
227
+ .replace(FEVER_RUBRIC_ASAP, FEVER_RUBRIC_ASAP_TIMER);
231
228
  }
232
229
  return live;
233
230
  }
234
231
 
235
- function chemoWindowOf(raw) {
236
- if (!isPlainObject(raw) || typeof raw.on_cytotoxic_chemo !== 'boolean') return null;
237
- const days = raw.days_since_last_cycle;
238
- return { onCytotoxicChemo: raw.on_cytotoxic_chemo, daysSinceLastCycle: Number.isInteger(days) && days >= 0 ? days : null };
239
- }
240
-
241
232
  const yesNo = (value) => (value === true ? 'sí' : value === false ? 'no' : null);
242
233
 
243
234
  function knownExplanationNote(known) {
@@ -334,7 +325,6 @@ async function handler(args, context = {}) {
334
325
  indicators: (Array.isArray(details.indicators) ? details.indicators : [])
335
326
  .filter((indicator) => typeof indicator === 'string' && indicator.trim()),
336
327
  turnId: runtime.turnId || null,
337
- chemoWindow: chemoWindowOf(details.chemo_window),
338
328
  ...(clinicalAssessment ? { clinicalAssessment } : {}),
339
329
  },
340
330
  })
@@ -16,11 +16,6 @@ const DOCTOR_DECISIONS = ['approved', 'edited_approved', 'needs_intervention', '
16
16
  const DECISION_SOURCES = ['platform', 'registered_by_navigator', 'chat'];
17
17
  const RETURN_REASONS = ['llamar', 'falta_dato', 'no_requiere'];
18
18
 
19
- const chemoWindowSchema = new mongoose.Schema({
20
- onCytotoxicChemo: { type: Boolean, default: null },
21
- daysSinceLastCycle: { type: Number, default: null },
22
- }, { _id: false });
23
-
24
19
  const alarmSchema = new mongoose.Schema({
25
20
  alarmId: { type: String, default: null },
26
21
  urgencyLevel: { type: String, enum: URGENCY_LEVELS, required: true },
@@ -35,7 +30,6 @@ const alarmSchema = new mongoose.Schema({
35
30
  clinicalAssessment: { type: mongoose.Schema.Types.Mixed, default: null },
36
31
  emergencyRecordId: { type: String, default: null },
37
32
  appliedLevel: { type: String, default: null },
38
- chemoWindow: { type: chemoWindowSchema, default: null },
39
33
  raisedAt: { type: Date, required: true },
40
34
  }, { _id: false });
41
35
 
@@ -5,7 +5,7 @@ const { httpError, requiredText } = require('../utils/httpErrorUtils');
5
5
  const { inlineOf } = require('../utils/coercionUtils');
6
6
  const { logger } = require('../utils/logger');
7
7
  const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
8
- const { isoDay } = require('../utils/dateUtils');
8
+ const { isoDay, mexicoDay } = require('../utils/dateUtils');
9
9
  const {
10
10
  CALL_OUTCOMES,
11
11
  CANCELLABLE_AFTER_HANDOFF_STATUSES,
@@ -33,6 +33,9 @@ const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelp
33
33
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
34
34
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
35
35
  const { feverPatientTimerLive: feverTimerLive } = require('../clinical/flags/escalationFlags');
36
+ const { chemoContextLive } = require('../clinical/flags/clinicalContextFlags');
37
+ const { daysSinceLastCytotoxic } = require('../clinical/helpers/chemoContextHelper');
38
+ const { readRecordedChemo } = require('../clinical/helpers/clinicalProfileHelper');
36
39
  const { composePrompt } = require('../clinical/services/promptComposerService');
37
40
  const { storeContextFacts, readContextFacts } = require('../fhir/services/clinicalContextService');
38
41
 
@@ -933,21 +936,14 @@ const appliedLevelOf = (alarm) => alarm.appliedLevel || alarm.urgencyLevel;
933
936
  const feverAlarmsOf = (review) => currentAlarms(review)
934
937
  .filter((alarm) => appliedLevelOf(alarm) === 'asap' && alarm.category === FEVER_CATEGORY);
935
938
 
936
- // The timer is only for a patient confirmed on cytotoxic chemotherapy with a known last cycle inside the
937
- // window; anything short of that (unknown, off chemo, no date, too long ago) leaves it unarmed.
938
- function chemoWindowGap(alarm) {
939
- const chemo = alarm?.chemoWindow;
940
- if (!chemo) return 'chemo_window_missing';
941
- if (chemo.onCytotoxicChemo !== true) return 'not_on_cytotoxic_chemo';
942
- const days = chemo.daysSinceLastCycle;
943
- if (!Number.isInteger(days)) return 'days_since_last_cycle_missing';
944
- return days >= 0 && days <= feverChemoWindowDays() ? null : 'outside_chemo_window';
945
- }
946
-
947
- function feverTimerGap(review) {
948
- const fevers = feverAlarmsOf(review);
949
- if (fevers.some((alarm) => !chemoWindowGap(alarm))) return null;
950
- return chemoWindowGap(fevers.at(-1));
939
+ async function feverTimerGap(patientCode, now) {
940
+ if (!chemoContextLive()) return 'chemo_context_off';
941
+ const chemo = await readRecordedChemo(patientCode);
942
+ if (chemo === undefined) return 'chemo_record_unreadable';
943
+ if (!chemo?.ultimaCitotoxica) return 'no_recorded_cytotoxic';
944
+ const days = daysSinceLastCytotoxic(chemo, mexicoDay(now));
945
+ if (days < 0) return 'last_cycle_in_future';
946
+ return days > feverChemoWindowDays() ? 'outside_chemo_window' : null;
951
947
  }
952
948
 
953
949
  // The timer runs from the first asap alert of this episode, so a retried alert never restarts it and a
@@ -1001,10 +997,16 @@ async function feverTakenReason(review, since, ownStart = null) {
1001
997
 
1002
998
  async function armFeverTimerOnJoin(review, alarm, now) {
1003
999
  try {
1004
- if (!feverTimerLive() || appliedLevelOf(alarm) !== 'asap' || alarm.category !== FEVER_CATEGORY || chemoWindowGap(alarm)) return;
1000
+ if (!feverTimerLive() || appliedLevelOf(alarm) !== 'asap' || alarm.category !== FEVER_CATEGORY) return;
1005
1001
  if (alertedRankOf(review) !== URGENCY_RANK.asap) return;
1006
1002
  const since = feverTimerSince(review);
1007
1003
  if (!since || promptedSince(review, since)) return;
1004
+ if (review.doctorAlertDueAt) return;
1005
+ const gap = await feverTimerGap(review.patientCode, now);
1006
+ if (gap) {
1007
+ await audit(review, ALERT_OPERATOR, null, { action: 'patient_prompt_not_armed', reason: gap });
1008
+ return;
1009
+ }
1008
1010
  // The patient gets the full timer from when the case became eligible, not from the earlier alert.
1009
1011
  await getEscalationReview().updateOne(
1010
1012
  { _id: review._id, doctorAlertDueAt: null },
@@ -1186,10 +1188,10 @@ async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date
1186
1188
  // while there is time before the prompt and attempts left, then the patient prompt is the safety net.
1187
1189
  // Stat already sends the patient to the ED.
1188
1190
  const latestReview = updated ?? review;
1189
- const feverCase = urgencyLevel === 'asap' && feverTimerLive() && feverAlarmsOf(latestReview).length > 0;
1190
- const feverGap = feverCase ? feverTimerGap(latestReview) : null;
1191
1191
  const since = feverTimerSince(latestReview) ?? now;
1192
- const promptAt = feverCase && !feverGap && !promptedSince(latestReview, since) ? since.getTime() + feverTimerMs() : null;
1192
+ const feverCase = urgencyLevel === 'asap' && feverTimerLive() && feverAlarmsOf(latestReview).length > 0 && !promptedSince(latestReview, since);
1193
+ const feverGap = feverCase ? await feverTimerGap(review.patientCode, now) : null;
1194
+ const promptAt = feverCase && !feverGap ? since.getTime() + feverTimerMs() : null;
1193
1195
  const retriesDoctor = sent.error
1194
1196
  && (promptAt == null || (now.getTime() + ALERT_RETRY_DELAY_MS < promptAt && (review.doctorAlertAttempts ?? 0) < MAX_ALERT_ATTEMPTS));
1195
1197
  if (promptAt != null && !retriesDoctor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.12",
3
+ "version": "5.71.13",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",