@peopl-health/nexus 5.71.1 → 5.71.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,27 @@ function contextVerifierLive() {
10
10
  return flagOn('CLINICAL_CONTEXT_VERIFIER_LIVE');
11
11
  }
12
12
 
13
+ function chemoContextLive() {
14
+ return flagOn('CHEMO_CONTEXT_LIVE');
15
+ }
16
+
17
+ function profileFromMongo() {
18
+ return flagOn('PATIENT_PROFILE_FROM_MONGO');
19
+ }
20
+
21
+ function profileSyncOn() {
22
+ return flagOn('PATIENT_PROFILE_SYNC');
23
+ }
24
+
13
25
  function contextMirrorLive() {
14
26
  return flagOn('CLINICAL_CONTEXT_MIRROR_LIVE') && contextReadLive();
15
27
  }
16
28
 
17
29
  module.exports = {
30
+ chemoContextLive,
18
31
  contextReadLive,
19
32
  contextMirrorLive,
20
33
  contextVerifierLive,
34
+ profileFromMongo,
35
+ profileSyncOn,
21
36
  };
@@ -0,0 +1,165 @@
1
+ const { mexicoDay } = require('../../utils/dateUtils');
2
+ const { foldAccents } = require('../../utils/formatUtils');
3
+ const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
4
+ const { logger } = require('../../utils/logger');
5
+
6
+ const ACTIVE_DAYS = 35;
7
+ const HEADER = 'Quimioterapia (registro de la unidad de quimioterapia; úsalo para contextualizar y priorizar síntomas, nunca para bajar la urgencia; no comuniques dosis ni ajustes de tratamiento; si el paciente dice otra cosa, créele y avisa al equipo)';
8
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
9
+ const NO_ALLERGY = /^(ninguna?s?|no|negadas?|desconocidas?|(niega|no refiere|sin|no tiene|no presenta) alergias?( conocidas| a medicamentos)?)$/;
10
+ const NEGATION = /\b(sin|niega|no|ninguna?)\b/;
11
+
12
+ const isDay = (value) => typeof value === 'string' && ISO_DAY.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
13
+ const dmy = (iso) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
14
+ const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000);
15
+ const addDays = (iso, days) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
16
+ const fold = (value) => foldAccents(String(value || '')).toLowerCase().trim();
17
+ const text = (value) => (typeof value === 'string' ? value.trim() : '');
18
+ const list = (value) => (Array.isArray(value) ? value : []);
19
+ const texts = (value) => list(value).map(text).filter(Boolean);
20
+ const dayOr = (value) => (isDay(value) ? value : null);
21
+
22
+ function normalize(raw) {
23
+ const last = isPlainObject(raw.ultimaAplicacion) && isDay(raw.ultimaAplicacion.fecha) ? raw.ultimaAplicacion : null;
24
+ const nadir = Array.isArray(raw.nadirDias) && raw.nadirDias.length === 2 && raw.nadirDias.every(Number.isInteger) ? raw.nadirDias : null;
25
+ const dated = (value, keys) => (isPlainObject(value) && isDay(value.fecha) && keys.every((k) => k in value) ? value : null);
26
+ return {
27
+ ultimaAplicacion: last,
28
+ esquema: list(raw.esquema).filter(isPlainObject).filter((e) => text(e.farmaco)),
29
+ vigilancia: texts(raw.vigilancia),
30
+ nadirDias: nadir,
31
+ premedicacion: texts(raw.premedicacion),
32
+ enCasa: list(raw.enCasa).filter(isPlainObject).filter((m) => text(m.farmaco)),
33
+ inicioEsquema: dated(raw.inicioEsquema, ['anteriorAlRegistro']),
34
+ proximaSesion: dated(raw.proximaSesion, ['tipo']),
35
+ noAplicadas: list(raw.noAplicadas).filter(isDay),
36
+ reaccionInfusion: dated(raw.reaccionInfusion, ['texto']),
37
+ alergias: dated(raw.alergias, ['estado', 'texto']),
38
+ acceso: text(raw.acceso),
39
+ peso: dated(raw.peso, ['kg']),
40
+ signosAnormales: dated(raw.signosAnormales, ['hallazgos']),
41
+ ajusteProximoCiclo: dated(raw.ajusteProximoCiclo, ['valor']),
42
+ esquemaPrevio: isPlainObject(raw.esquemaPrevio) && isDay(raw.esquemaPrevio.hasta) ? { farmacos: texts(raw.esquemaPrevio.farmacos), hasta: raw.esquemaPrevio.hasta } : null,
43
+ visitaSoporte: dated(raw.visitaSoporte, ['farmacos']),
44
+ antecedentes: dated(raw.antecedentes, []),
45
+ };
46
+ }
47
+
48
+ function parseChemoContext(raw) {
49
+ const parsed = safeParse(raw, null);
50
+ return isPlainObject(parsed) && parsed.version === 1 ? normalize(parsed) : null;
51
+ }
52
+
53
+ function chemoStatus(context, today) {
54
+ const last = context.ultimaAplicacion?.fecha;
55
+ if (!last) return { active: false, days: null };
56
+ const days = daysBetween(last, today);
57
+ if (days < 0) return { active: true, days: null };
58
+ const scheduled = context.proximaSesion && context.proximaSesion.fecha >= today;
59
+ return { active: days <= ACTIVE_DAYS || Boolean(scheduled), days };
60
+ }
61
+
62
+ function regimenText(context) {
63
+ return context.esquema.map((e) => [e.farmaco, e.via, e.tiempo].map(text).filter(Boolean).join(' ')).join('; ');
64
+ }
65
+
66
+ function statusLine(last, active, days) {
67
+ const cycle = [last.ciclo ? `ciclo ${last.ciclo}` : '', last.sesion ? `sesión ${last.sesion}${last.total ? `/${last.total}` : ''}` : ''].filter(Boolean).join(', ');
68
+ const when = days === null ? `última aplicación registrada ${dmy(last.fecha)}` : `última aplicación ${dmy(last.fecha)} (hace ${days} días)`;
69
+ return active
70
+ ? `Estado: en quimioterapia activa; ${when}${cycle ? `, ${cycle}` : ''}`
71
+ : `Estado: sin aplicaciones registradas en la unidad desde ${dmy(last.fecha)} (hace ${days} días); confirma con el paciente si sigue en tratamiento`;
72
+ }
73
+
74
+ function chemoLines(context, today) {
75
+ const lines = [];
76
+ const last = context.ultimaAplicacion;
77
+ const { active, days } = chemoStatus(context, today);
78
+ if (last) {
79
+ lines.push(statusLine(last, active, days));
80
+ const regimen = regimenText(context);
81
+ if (regimen) lines.push(`${active ? 'Esquema actual' : 'Último esquema'}: ${regimen}`);
82
+ if (context.vigilancia.length) lines.push(`Vigilar por sus fármacos: ${context.vigilancia.join('; ')}`);
83
+ if (active && days !== null && context.nadirDias && days <= context.nadirDias[1]) {
84
+ const [from, to] = context.nadirDias;
85
+ lines.push(`Mayor riesgo de defensas bajas: días ${from} a ${to} después de la aplicación (${dmy(addDays(last.fecha, from))} al ${dmy(addDays(last.fecha, to))}); hoy es el día ${days}. Fiebre de 38 °C o más es urgente cualquier día`);
86
+ }
87
+ }
88
+ if (context.enCasa.length) {
89
+ lines.push(`Se fue a casa con: ${context.enCasa.map((m) => `${text(m.farmaco)}${text(m.indicacion) ? ` (${text(m.indicacion)})` : ''}${m.esteroide ? ' [esteroide: puede ocultar la fiebre]' : ''}`).join('; ')}`);
90
+ }
91
+ if (context.premedicacion.length) lines.push(`Premedicación en la unidad: ${context.premedicacion.join(', ')}`);
92
+ if (context.inicioEsquema) {
93
+ lines.push(context.inicioEsquema.anteriorAlRegistro
94
+ ? `Inicio de este esquema: antes del ${dmy(context.inicioEsquema.fecha)} (inicio del registro)`
95
+ : `Inicio de este esquema: ${dmy(context.inicioEsquema.fecha)}`);
96
+ }
97
+ if (context.proximaSesion) {
98
+ lines.push(context.proximaSesion.tipo === 'agendada'
99
+ ? `Próxima sesión: ${dmy(context.proximaSesion.fecha)} (agendada)`
100
+ : `Próxima sesión: ${dmy(context.proximaSesion.fecha)} (estimada; no la confirmes como cita)`);
101
+ }
102
+ if (context.noAplicadas.length) lines.push(`Sesiones no aplicadas en los últimos 90 días: ${context.noAplicadas.map(dmy).join(', ')}`);
103
+ if (text(context.reaccionInfusion?.texto)) lines.push(`Reacción durante la infusión registrada: ${text(context.reaccionInfusion.texto)} (${dmy(context.reaccionInfusion.fecha)})`);
104
+ if (context.acceso) lines.push(`Acceso venoso: ${context.acceso}`);
105
+ if (Number.isFinite(context.peso?.kg)) {
106
+ const pct = context.peso.cambioPct;
107
+ const change = Number.isFinite(pct) && isDay(context.peso.desde) ? `; cambio ${pct > 0 ? '+' : ''}${pct}% desde ${dmy(context.peso.desde)}` : '';
108
+ lines.push(`Peso: ${context.peso.kg} kg (${dmy(context.peso.fecha)})${change}`);
109
+ }
110
+ const findings = texts(context.signosAnormales?.hallazgos);
111
+ if (findings.length) lines.push(`Signos fuera de rango en la última sesión (${dmy(context.signosAnormales.fecha)}): ${findings.join(', ')}`);
112
+ if (context.ajusteProximoCiclo?.valor === true) lines.push(`El médico indicó un ajuste para el próximo ciclo (${dmy(context.ajusteProximoCiclo.fecha)}): no lo comuniques, lo explica el médico`);
113
+ if (context.esquemaPrevio?.farmacos.length) lines.push(`Esquema previo: ${context.esquemaPrevio.farmacos.join(', ')}, hasta ${dmy(context.esquemaPrevio.hasta)}`);
114
+ if (context.visitaSoporte) {
115
+ const drugs = texts(context.visitaSoporte.farmacos);
116
+ lines.push(`Última visita a la unidad sin quimioterapia: ${dmy(context.visitaSoporte.fecha)}${drugs.length ? ` (${drugs.join(', ')})` : ''}`);
117
+ }
118
+ const h = context.antecedentes;
119
+ if (h) {
120
+ const parts = [['diabetes', h.diabetes], ['hipertensión', h.hipertension], ['otros', h.otros], ['histología', h.histologia], ['tratamiento previo', h.tratamientoPrevio]]
121
+ .filter(([, value]) => text(value)).map(([label, value]) => `${label}: ${text(value)}`);
122
+ if (parts.length) lines.push(`Antecedentes (historia de primera vez, enfermería, ${dmy(h.fecha)}): ${parts.join('; ')}`);
123
+ }
124
+ return lines;
125
+ }
126
+
127
+ function renderChemoContext(context, today = mexicoDay(new Date())) {
128
+ if (!context) return '';
129
+ try {
130
+ const lines = chemoLines(context, today);
131
+ return lines.length ? `${HEADER}:\n- ${lines.join('\n- ')}` : '';
132
+ } catch (error) {
133
+ logger.warn('[chemoContext] section left out', { error: error.message });
134
+ return '';
135
+ }
136
+ }
137
+
138
+ function listsAllergy(existing, allergy) {
139
+ const held = fold(existing);
140
+ if (NEGATION.test(held)) return false;
141
+ return new RegExp(`(^|[^a-z0-9])${fold(allergy).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`).test(held);
142
+ }
143
+
144
+ function withChemoContext(clinical, context, today = mexicoDay(new Date())) {
145
+ if (!context) return clinical || {};
146
+ try {
147
+ const merged = { ...(clinical || {}) };
148
+ if (!text(merged.tratamiento_actual) && chemoStatus(context, today).active && context.esquema.length) {
149
+ merged.tratamiento_actual = `${regimenText(context)} (según la unidad de quimioterapia)`;
150
+ }
151
+ const allergies = context.alergias;
152
+ const nursing = text(allergies?.texto);
153
+ if (allergies?.estado === 'si' && nursing && !listsAllergy(merged.alergias_medicamentos, nursing)) {
154
+ const recorded = `${nursing} (registrado por enfermería, ${dmy(allergies.fecha)})`;
155
+ const held = text(merged.alergias_medicamentos);
156
+ merged.alergias_medicamentos = !held || NO_ALLERGY.test(fold(held)) ? recorded : `${held}; ${recorded}`;
157
+ }
158
+ return merged;
159
+ } catch (error) {
160
+ logger.warn('[chemoContext] merge skipped', { error: error.message });
161
+ return clinical || {};
162
+ }
163
+ }
164
+
165
+ module.exports = { parseChemoContext, renderChemoContext, withChemoContext };
@@ -1,5 +1,4 @@
1
- const { resolvePatientIdentity } = require('../services/patientIdentityService');
2
- const { Follow_Up_ID } = require('../../config/airtableConfig');
1
+ const { resolveProfileIdentity } = require('../services/patientProfileService');
3
2
  const { safeParse } = require('../../utils/jsonUtils');
4
3
 
5
4
  const CONTEXT_FIELD = 'clinical-context-json';
@@ -35,7 +34,7 @@ function richestProfile(records) {
35
34
 
36
35
  async function readClinicalProfile(code) {
37
36
  try {
38
- const identity = await resolvePatientIdentity(Follow_Up_ID, code);
37
+ const identity = await resolveProfileIdentity(code);
39
38
  if (identity.kind === 'missing') return null;
40
39
  if (identity.kind !== 'resolved') return undefined;
41
40
  return richestProfile([identity.record]);
@@ -1,9 +1,12 @@
1
1
  const { pairTriageSymptoms } = require('../helpers/triageSymptomsHelper');
2
+ const { parseChemoContext, renderChemoContext, withChemoContext } = require('../helpers/chemoContextHelper');
2
3
  const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
3
4
 
4
5
  const { assembleRiskHeadline } = require('../services/riskHeadlineService');
5
6
  const { resolveNutritionPlan } = require('../services/nutritionPlanService');
6
- const { resolvePatientIdentity, identityError } = require('../services/patientIdentityService');
7
+ const { identityError } = require('../services/patientIdentityService');
8
+ const { resolveProfileIdentity } = require('../services/patientProfileService');
9
+ const { chemoContextLive } = require('../flags/clinicalContextFlags');
7
10
  const { Follow_Up_ID } = require('../../config/airtableConfig');
8
11
  const runtimeConfig = require('../../config/runtimeConfig');
9
12
 
@@ -269,7 +272,7 @@ class DefaultMemoryManager extends MemoryManager {
269
272
 
270
273
  async _getClinicalContext(whatsappId) {
271
274
  try {
272
- const identity = await resolvePatientIdentity(Follow_Up_ID, whatsappId);
275
+ const identity = await resolveProfileIdentity(whatsappId);
273
276
  if (identity.kind === 'ambiguous') throw identityError(identity.kind);
274
277
  const record = identity.record || {};
275
278
  const symptomsRecords = await getRecordByFilter(Follow_Up_ID, 'all_triages_last_month', fieldEquals('whatsapp_id', whatsappId));
@@ -278,13 +281,14 @@ class DefaultMemoryManager extends MemoryManager {
278
281
  ? JSON.parse(record['clinical-context-json'])
279
282
  : null;
280
283
  const treatingPhysician = sanitizeInlineText(record.doctor || '', Infinity);
284
+ const chemo = chemoContextLive() ? parseChemoContext(record.quimio_context) : null;
281
285
 
282
286
  const sortedSymptoms = symptomsRecords?.filter(r => r.Date)
283
287
  .sort((a, b) => new Date(b.Date) - new Date(a.Date)).slice(0, 3) || [];
284
288
 
285
289
  const symptoms = this._parseSymptoms(sortedSymptoms);
286
290
 
287
- return { ...clinicalContext, symptoms, treatingPhysician };
291
+ return { ...clinicalContext, symptoms, treatingPhysician, chemo };
288
292
  } catch (error) {
289
293
  logger.error('[DefaultMemoryManager] Error fetching clinical context', { error: error.message });
290
294
  return null;
@@ -331,7 +335,7 @@ class DefaultMemoryManager extends MemoryManager {
331
335
 
332
336
  const contextParts = [];
333
337
  const d = data.demografia;
334
- const c = data.datos_clinicos;
338
+ const c = withChemoContext(data.datos_clinicos, data.chemo);
335
339
  if (d?.edad) contextParts.push(`Edad: ${d.edad}`);
336
340
  if (d?.sexo) contextParts.push(`Sexo: ${d.sexo}`);
337
341
  if (data.treatingPhysician) contextParts.push(`Médico tratante: ${data.treatingPhysician}`);
@@ -350,7 +354,7 @@ class DefaultMemoryManager extends MemoryManager {
350
354
  }).filter(Boolean);
351
355
 
352
356
  return {
353
- clinicalContext: [contextParts.join('. '), riskHeadline].filter(Boolean).join('\n\n'),
357
+ clinicalContext: [contextParts.join('. '), renderChemoContext(data.chemo), riskHeadline].filter(Boolean).join('\n\n'),
354
358
  lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente',
355
359
  nutritionPlan: await resolveNutritionPlan(data.nutricion),
356
360
  };
@@ -0,0 +1,145 @@
1
+ const { isShadowRun } = require('../helpers/shadowHelper');
2
+ const { profileFromMongo } = require('../flags/clinicalContextFlags');
3
+ const { Estado_General_ID, Follow_Up_ID } = require('../../config/airtableConfig');
4
+ const runtimeConfig = require('../../config/runtimeConfig');
5
+ const { MATCH_ALL } = require('../../helpers/airtableFormulaHelper');
6
+ const { getContextSyncState } = require('../../models/contextSyncStateModel');
7
+ const { getPatientProfile } = require('../../models/patientProfileModel');
8
+ const { getRecordByFilter } = require('../../services/airtableService');
9
+ const { inlineOf } = require('../../utils/coercionUtils');
10
+ const { createIntervalSweeper } = require('../../utils/intervalSweeper');
11
+ const { logger } = require('../../utils/logger');
12
+ const { resolvePatientIdentity } = require('./patientIdentityService');
13
+
14
+ const TABLE = 'estado_general';
15
+ const FIELDS = ['whatsapp_id', 'clinical-context-json', 'last_modified_clinical_context', 'doctor', 'tipo', 'source', 'quimio_context'];
16
+ const JOB = 'patient-profile';
17
+ const MS_PER_MINUTE = 60 * 1000;
18
+ const DEFAULT_INTERVAL_MS = 2 * MS_PER_MINUTE;
19
+ const OVERLAP_MS = 5 * MS_PER_MINUTE;
20
+ const RECONCILE_MS = 24 * 60 * MS_PER_MINUTE;
21
+ const MAX_REMOVAL_SHARE = 0.05;
22
+ const MISSING_TTL_MS = 2 * MS_PER_MINUTE;
23
+ const missingUntil = new Map();
24
+
25
+ const intervalMs = () => Number(runtimeConfig.get('PATIENT_PROFILE_SYNC_INTERVAL_MS', DEFAULT_INTERVAL_MS));
26
+
27
+ function changedSince(watermark) {
28
+ const from = new Date(new Date(watermark).getTime() - OVERLAP_MS).toISOString();
29
+ return `IS_AFTER(LAST_MODIFIED_TIME(), DATETIME_PARSE('${from}'))`;
30
+ }
31
+
32
+ function toProfile(row, syncedAt) {
33
+ const fields = Object.fromEntries(FIELDS.filter((f) => row[f] !== undefined).map((f) => [f, row[f]]));
34
+ return { recordId: row._recordId, whatsappId: inlineOf(row.whatsapp_id) || null, fields, syncedAt };
35
+ }
36
+
37
+ async function readState() {
38
+ return (await getContextSyncState().findOne({ job: JOB }).lean()) || { job: JOB, watermark: null, fullReconciledAt: null };
39
+ }
40
+
41
+ async function saveState({ startedAt, written, reason = null, reconciled = false, advance = true }) {
42
+ await getContextSyncState().updateOne(
43
+ { job: JOB },
44
+ {
45
+ $set: { job: JOB, lastRunAt: startedAt, lastWritten: written, lastReason: reason, ...(reconciled ? { fullReconciledAt: startedAt } : {}) },
46
+ ...(advance ? { $max: { watermark: startedAt } } : {}),
47
+ },
48
+ { upsert: true },
49
+ );
50
+ }
51
+
52
+ async function upsertProfiles(rows, syncedAt) {
53
+ const usable = rows.filter((row) => /^rec[A-Za-z0-9]+$/.test(row._recordId || ''));
54
+ if (!usable.length) return 0;
55
+ await getPatientProfile().bulkWrite(usable.map((row) => {
56
+ const profile = toProfile(row, syncedAt);
57
+ return { updateOne: { filter: { recordId: profile.recordId }, update: { $set: profile }, upsert: true } };
58
+ }), { ordered: false });
59
+ return usable.length;
60
+ }
61
+
62
+ async function removeUnseen(seen, startedAt) {
63
+ const Model = getPatientProfile();
64
+ const unseen = { recordId: { $nin: seen }, syncedAt: { $lt: startedAt } };
65
+ const stale = await Model.countDocuments(unseen);
66
+ const stored = await Model.countDocuments({});
67
+ if (stale > Math.max(1, stored * MAX_REMOVAL_SHARE)) {
68
+ logger.error('[PatientProfileSync] A full read would remove too many profiles; nothing removed', { stale, stored });
69
+ return { removed: 0, reason: 'removal-skipped' };
70
+ }
71
+ return { removed: stale ? (await Model.deleteMany(unseen)).deletedCount || 0 : 0, reason: null };
72
+ }
73
+
74
+ async function syncPatientProfiles({ now = new Date() } = {}) {
75
+ const state = await readState();
76
+ const empty = (await getPatientProfile().countDocuments({})) === 0;
77
+ const reconcile = empty || !state.watermark || !state.fullReconciledAt || now - new Date(state.fullReconciledAt) >= RECONCILE_MS;
78
+ let rows;
79
+ try {
80
+ rows = await getRecordByFilter(Estado_General_ID, TABLE, reconcile ? MATCH_ALL : changedSince(state.watermark), null, FIELDS, true);
81
+ } catch (error) {
82
+ await saveState({ startedAt: now, written: 0, reason: 'airtable-unavailable', advance: false });
83
+ throw error;
84
+ }
85
+ const written = await upsertProfiles(rows, now);
86
+ const { removed, reason } = reconcile ? await removeUnseen(rows.map((row) => row._recordId).filter(Boolean), now) : { removed: 0, reason: null };
87
+ await saveState({ startedAt: now, written, reason, reconciled: reconcile });
88
+ return { ok: true, written, removed, reconciled: reconcile, reason };
89
+ }
90
+
91
+ let storeReady = false;
92
+
93
+ async function profileStoreReady() {
94
+ if (!storeReady) storeReady = Boolean((await readState()).fullReconciledAt);
95
+ return storeReady;
96
+ }
97
+
98
+ async function fromAirtable(code, docs = []) {
99
+ const identity = await resolvePatientIdentity(Estado_General_ID, code);
100
+ if (identity.kind !== 'resolved' || isShadowRun()) return identity;
101
+ const keep = async () => {
102
+ await upsertProfiles([{ ...identity.record, _recordId: identity.recordId }], new Date());
103
+ const gone = docs.map((d) => d.recordId).filter((id) => id !== identity.recordId);
104
+ if (!gone.length) return;
105
+ logger.warn('[PatientProfile] Removing stored profiles Airtable no longer has for this number', { removed: gone, kept: identity.recordId });
106
+ await getPatientProfile().deleteMany({ recordId: { $in: gone } });
107
+ };
108
+ await keep().catch((error) => logger.warn('[PatientProfile] Could not keep a profile read from Airtable', { error: error.message }));
109
+ return identity;
110
+ }
111
+
112
+ async function resolveProfileIdentity(code) {
113
+ if (!profileFromMongo()) return resolvePatientIdentity(Follow_Up_ID, code);
114
+ if (typeof code !== 'string' || !code.trim()) return resolvePatientIdentity(Estado_General_ID, code);
115
+ const key = inlineOf(code);
116
+ if ((missingUntil.get(key) || 0) > Date.now()) return { kind: 'missing' };
117
+ let docs;
118
+ try {
119
+ if (!(await profileStoreReady())) return resolvePatientIdentity(Estado_General_ID, code);
120
+ docs = await getPatientProfile().find({ whatsappId: key }).lean();
121
+ } catch (error) {
122
+ logger.warn('[PatientProfile] Mongo unavailable; reading Airtable', { error: error.message });
123
+ return resolvePatientIdentity(Estado_General_ID, code);
124
+ }
125
+ if (docs.length === 1) return { kind: 'resolved', recordId: docs[0].recordId, record: { ...docs[0].fields } };
126
+ const identity = await fromAirtable(code, docs);
127
+ if (identity.kind === 'missing') missingUntil.set(key, Date.now() + MISSING_TTL_MS);
128
+ return identity;
129
+ }
130
+
131
+ const sweeper = createIntervalSweeper({
132
+ label: '[PatientProfileSync]',
133
+ sweep: () => syncPatientProfiles(),
134
+ defaultIntervalMs: intervalMs,
135
+ });
136
+
137
+ function startPatientProfileSync(options) {
138
+ const timer = sweeper.start(options);
139
+ syncPatientProfiles().catch((error) => logger.error('[PatientProfileSync] First sync failed', { error: error.message }));
140
+ return timer;
141
+ }
142
+
143
+ const stopPatientProfileSync = () => sweeper.stop();
144
+
145
+ module.exports = { resolveProfileIdentity, syncPatientProfiles, startPatientProfileSync, stopPatientProfileSync };
@@ -10,6 +10,7 @@ const { stopReviewDraftSweep } = require('../services/reviewDraftSweepService');
10
10
  const { stopReviewAlertSweep } = require('../services/reviewAlertSweepService');
11
11
  const { clearInteractiveRoutes } = require('../core/interactiveRouteService');
12
12
  const { _resetDefaultInstance } = require('../core/NexusMessaging');
13
+ const { stopPatientProfileSync } = require('../clinical/services/patientProfileService');
13
14
  const { _resetSafetyNetConfig } = require('../clinical/config/safetyNetConfig');
14
15
  const { _resetSafetyNetState } = require('../clinical/services/pausedSafetyNetService');
15
16
  const { stopSafetyNetSweep } = require('../clinical/services/safetyNetPledgeService');
@@ -24,6 +25,7 @@ const SWEEP_STOP_MS = 2000;
24
25
  function stopAllSweeps({ timeoutMs = SWEEP_STOP_MS, label = '[lifecycle]' } = {}) {
25
26
  const stopped = Promise.allSettled([
26
27
  stopSafetyNetSweep(), stopRecommendationRecoverySweep(), stopInboundRecoverySweep(), stopReviewDraftSweep(), stopReviewAlertSweep(),
28
+ stopPatientProfileSync(),
27
29
  ]);
28
30
  return withTimeout(stopped, timeoutMs, 'sweeps.stop')
29
31
  .catch(error => logger.warn(`${label} A sweep was still running`, { error: error.message }));
package/lib/index.js CHANGED
@@ -34,6 +34,8 @@ const { startRecommendationRecoverySweep } = require('./clinical/services/recomm
34
34
  const { startInboundRecoverySweep } = require('./services/inboundRecoveryService');
35
35
  const { startReviewDraftSweep } = require('./services/reviewDraftSweepService');
36
36
  const { startReviewAlertSweep } = require('./services/reviewAlertSweepService');
37
+ const { startPatientProfileSync } = require('./clinical/services/patientProfileService');
38
+ const { profileSyncOn } = require('./clinical/flags/clinicalContextFlags');
37
39
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
38
40
  const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
39
41
  const { DrainingError } = require('./core/BatchingManager');
@@ -215,6 +217,15 @@ class Nexus {
215
217
  logger.warn('Warning: failed to arm the clinical review draft sweep:', e?.message || e);
216
218
  }
217
219
 
220
+ try {
221
+ if (profileSyncOn()) {
222
+ startPatientProfileSync();
223
+ logger.info('[Nexus] patient profile sync armed');
224
+ }
225
+ } catch (e) {
226
+ logger.warn('Warning: failed to arm the patient profile sync:', e?.message || e);
227
+ }
228
+
218
229
  try {
219
230
  if (options.reviewAlerts?.enabled === true) {
220
231
  startReviewAlertSweep({ intervalMs: options.reviewAlerts.intervalMs });
@@ -0,0 +1,24 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const { getDb, getModelDatabase } = require('../config/mongoConfig');
4
+
5
+ const patientProfileSchema = new mongoose.Schema({
6
+ recordId: { type: String, required: true },
7
+ whatsappId: { type: String, default: null },
8
+ fields: { type: mongoose.Schema.Types.Mixed, default: {} },
9
+ syncedAt: { type: Date, default: null },
10
+ }, { timestamps: true, minimize: false });
11
+
12
+ patientProfileSchema.index({ recordId: 1 }, { unique: true });
13
+ patientProfileSchema.index({ whatsappId: 1 });
14
+
15
+ const getPatientProfile = () => {
16
+ const dbName = getModelDatabase('PatientProfile');
17
+ const db = dbName ? getDb(dbName) : mongoose;
18
+ return db.models.PatientProfile || db.model('PatientProfile', patientProfileSchema);
19
+ };
20
+
21
+ module.exports = {
22
+ getPatientProfile,
23
+ patientProfileSchema,
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.1",
3
+ "version": "5.71.3",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",