@peopl-health/nexus 5.69.0-dev.12622 → 5.69.0-dev.12624

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.
@@ -6,6 +6,7 @@ const { getMessages } = require('../../services/messageService');
6
6
  const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
7
7
  const { logger } = require('../../utils/logger');
8
8
  const { botIsPaused } = require('../../helpers/pausedThreadHelper');
9
+ const { Thread } = require('../../models/threadModel');
9
10
  const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
10
11
  const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
11
12
  const { WriteClaimStore } = require('./writeClaimStore');
@@ -53,13 +54,6 @@ const AUDIT = {
53
54
  const WEEKLY_SOURCE = 'weekly';
54
55
  const SCOPE_SOURCE = 'patient_scope';
55
56
 
56
- const PATIENT_TABLE = 'estado_general';
57
- const PATIENT_F = {
58
- whatsapp: 'whatsapp_id',
59
- firstName: 'first_name',
60
- disableFollowUp: 'desactivar_seguimiento_48hrs',
61
- };
62
-
63
57
  const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
64
58
 
65
59
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
@@ -216,32 +210,27 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
216
210
  return { candidates, targetDays, weekday, cadence, mode };
217
211
  }
218
212
 
219
- function buildScopeCandidates({ patientScope, patientRows, excludedCodes }) {
220
- const patients = new Map(patientScope.filter((code) => !excludedCodes.has(code))
221
- .map((code) => [code, { names: new Map(), recordId: null, followUpDisabled: false }]));
222
- for (const row of patientRows) {
223
- const patient = patients.get(String(first(row[PATIENT_F.whatsapp]) || '').trim());
224
- if (!patient) continue;
225
- const name = String(first(row[PATIENT_F.firstName]) || '').trim();
226
- if (name) patient.names.set(name.toLowerCase().replace(/\s+/g, ' '), name);
227
- patient.recordId = patient.recordId || row._recordId;
228
- if (row[PATIENT_F.disableFollowUp] === true) patient.followUpDisabled = true;
229
- }
213
+ function firstWord(value) {
214
+ return String(value || '').trim().split(/\s+/)[0];
215
+ }
216
+
217
+ function buildScopeCandidates({ threads, excludedCodes }) {
218
+ const threadsByCode = new Map();
219
+ for (const thread of threads) threadsByCode.set(thread.code, [...(threadsByCode.get(thread.code) || []), thread]);
230
220
 
231
221
  const candidates = [];
232
- for (const [code, patient] of patients) {
233
- if (patient.names.size !== 1) {
234
- logger.info('[outreachSweep] scope candidate skipped — no single patient name', { recordId: patient.recordId });
235
- continue;
236
- }
222
+ for (const [code, docs] of threadsByCode) {
223
+ const [thread] = docs.sort((a, b) => String(a._id).localeCompare(String(b._id)));
224
+ const name = firstWord(thread.nombre);
225
+ const names = new Set(docs.map((doc) => firstWord(doc.nombre).toLowerCase()));
226
+ if (excludedCodes.has(code) || !name || names.size !== 1 || !thread.patient_id) continue;
237
227
  candidates.push({
238
228
  source: SCOPE_SOURCE,
239
- patientId: null,
240
- recordId: patient.recordId,
229
+ patientId: thread.patient_id,
230
+ recordId: String(thread._id),
241
231
  code,
242
- name: [...patient.names.values()][0],
232
+ name,
243
233
  reason: 'no_triage_history',
244
- followUpDisabled: patient.followUpDisabled,
245
234
  rows: [],
246
235
  brief: {
247
236
  reason: 'no hay un triaje contestado que sirva como ancla clínica',
@@ -256,15 +245,15 @@ function buildScopeCandidates({ patientScope, patientRows, excludedCodes }) {
256
245
 
257
246
  async function loadScopeCandidates({ now, viewRows, patientScope }) {
258
247
  const weekStart = startOfLocalWeek(now).toISOString();
259
- const [patientRows, weekRows] = await Promise.all([
260
- airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F), true),
248
+ const [threads, weekRows] = await Promise.all([
249
+ Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
261
250
  airtableService.getRecordByFilter(
262
251
  Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
263
252
  ),
264
253
  ]);
265
254
  const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
266
255
  .map((row) => String(first(row[F.whatsapp]) || '').trim()));
267
- return buildScopeCandidates({ patientScope, patientRows: patientRows || [], excludedCodes });
256
+ return buildScopeCandidates({ threads: threads || [], excludedCodes });
268
257
  }
269
258
 
270
259
  function sweepClaimKey(candidate, now) {
@@ -272,7 +261,6 @@ function sweepClaimKey(candidate, now) {
272
261
  }
273
262
 
274
263
  function opsOptedOut(candidate, { now }) {
275
- if (candidate.followUpDisabled) return 'patient_follow_up_disabled';
276
264
  const weekStart = startOfLocalWeek(now);
277
265
  for (const row of candidate.rows) {
278
266
  if (first(row[F.disableFollowUp]) === true) return 'patient_follow_up_disabled';
@@ -307,6 +295,7 @@ async function rejectionReason(candidate, {
307
295
  } = {}) {
308
296
  const opsReason = opsOptedOut(candidate, { now });
309
297
  if (opsReason) return opsReason;
298
+ if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'outreach_opt_out';
310
299
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
311
300
  if (!includePreviouslySwept) {
312
301
  const sweptReason = candidate.source === SCOPE_SOURCE
@@ -432,9 +421,8 @@ async function enrichBrief(candidate) {
432
421
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
433
422
  async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
434
423
  if (dryRun) return true;
435
- if (candidate.source === SCOPE_SOURCE) {
436
- return (await sweepClaims.acquire(sweepClaimKey(candidate, now))) === 'acquired';
437
- }
424
+ if ((await sweepClaims.acquire(sweepClaimKey(candidate, now))) !== 'acquired') return false;
425
+ if (candidate.source === SCOPE_SOURCE) return true;
438
426
  try {
439
427
  const claimed = await airtableService.updateRecordByFilter(
440
428
  Symptoms_ID,
@@ -649,7 +637,6 @@ module.exports = {
649
637
  mondayTriageStillPending,
650
638
  VIEW,
651
639
  F,
652
- PATIENT_F,
653
640
  AUDIT,
654
641
  sweepClaims,
655
642
  };
@@ -10,6 +10,7 @@ const threadSchema = new mongoose.Schema({
10
10
  run_id: { type: String, default: null },
11
11
  nombre: { type: String, default: null },
12
12
  stopped: { type: Boolean, default: false },
13
+ outreachOptOut: { type: Boolean, default: false },
13
14
  pausedAt: { type: Date, default: null },
14
15
  doNotAlertThrough: { type: Date, default: null },
15
16
  review: { type: Boolean, default: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.69.0-dev.12622",
3
+ "version": "5.69.0-dev.12624",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",