@peopl-health/nexus 5.69.0-dev.12645 → 5.70.0

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,8 +6,10 @@ 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');
12
+ const { WriteClaimStore } = require('./writeClaimStore');
11
13
 
12
14
  const TABLE = 'weekly_questionnaires';
13
15
  const VIEW = 'seguimiento_72hrs_principal';
@@ -49,6 +51,11 @@ const AUDIT = {
49
51
  turnId: 'seguimiento_turn_id',
50
52
  };
51
53
 
54
+ const WEEKLY_SOURCE = 'weekly';
55
+ const SCOPE_SOURCE = 'patient_scope';
56
+
57
+ const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
58
+
52
59
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
53
60
  const CONTACT_SENT = {
54
61
  from_me: true,
@@ -203,6 +210,56 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
203
210
  return { candidates, targetDays, weekday, cadence, mode };
204
211
  }
205
212
 
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]);
220
+
221
+ const candidates = [];
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;
227
+ candidates.push({
228
+ source: SCOPE_SOURCE,
229
+ patientId: thread.patient_id,
230
+ recordId: String(thread._id),
231
+ code,
232
+ name,
233
+ reason: 'no_triage_history',
234
+ rows: [],
235
+ brief: {
236
+ reason: 'no hay un triaje contestado que sirva como ancla clínica',
237
+ lastTriageAt: null,
238
+ maxGrade: null,
239
+ positiveSymptoms: [],
240
+ },
241
+ });
242
+ }
243
+ return candidates;
244
+ }
245
+
246
+ async function loadScopeCandidates({ now, viewRows, patientScope }) {
247
+ const weekStart = startOfLocalWeek(now).toISOString();
248
+ const [threads, weekRows] = await Promise.all([
249
+ Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
250
+ airtableService.getRecordByFilter(
251
+ Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
252
+ ),
253
+ ]);
254
+ const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
255
+ .map((row) => String(first(row[F.whatsapp]) || '').trim()));
256
+ return buildScopeCandidates({ threads: threads || [], excludedCodes });
257
+ }
258
+
259
+ function sweepClaimKey(candidate, now) {
260
+ return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
261
+ }
262
+
206
263
  function opsOptedOut(candidate, { now }) {
207
264
  const weekStart = startOfLocalWeek(now);
208
265
  for (const row of candidate.rows) {
@@ -238,9 +295,12 @@ async function rejectionReason(candidate, {
238
295
  } = {}) {
239
296
  const opsReason = opsOptedOut(candidate, { now });
240
297
  if (opsReason) return opsReason;
298
+ if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'outreach_opt_out';
241
299
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
242
300
  if (!includePreviouslySwept) {
243
- const sweptReason = priorSweepReason(candidate);
301
+ const sweptReason = candidate.source === SCOPE_SOURCE
302
+ ? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
303
+ : priorSweepReason(candidate);
244
304
  if (sweptReason) return sweptReason;
245
305
 
246
306
  const since = mode === 'monday_triage_recovery'
@@ -359,8 +419,10 @@ async function enrichBrief(candidate) {
359
419
  // so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
360
420
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
361
421
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
362
- async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
422
+ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
363
423
  if (dryRun) return true;
424
+ if ((await sweepClaims.acquire(sweepClaimKey(candidate, now))) !== 'acquired') return false;
425
+ if (candidate.source === SCOPE_SOURCE) return true;
364
426
  try {
365
427
  const claimed = await airtableService.updateRecordByFilter(
366
428
  Symptoms_ID,
@@ -383,7 +445,7 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
383
445
  }
384
446
 
385
447
  async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
386
- if (dryRun) return;
448
+ if (dryRun || candidate.source === SCOPE_SOURCE) return;
387
449
  // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
388
450
  // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
389
451
  let stamp;
@@ -422,6 +484,7 @@ async function runOutreachSweep({
422
484
  includePreviouslySwept = false,
423
485
  cadence = 'daily',
424
486
  mode = 'continuity',
487
+ source = WEEKLY_SOURCE,
425
488
  runOutreachTurn,
426
489
  } = {}) {
427
490
  if (typeof runOutreachTurn !== 'function') {
@@ -443,6 +506,12 @@ async function runOutreachSweep({
443
506
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
444
507
  throw new Error('mode must be continuity or monday_triage_recovery');
445
508
  }
509
+ if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
510
+ throw new Error('source must be weekly or patient_scope');
511
+ }
512
+ if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
513
+ throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
514
+ }
446
515
 
447
516
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
448
517
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
@@ -460,7 +529,9 @@ async function runOutreachSweep({
460
529
  const rows = await airtableService.getRecordByFilter(
461
530
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
462
531
  );
463
- const { candidates, targetDays, weekday } = buildCandidates(rows || [], { now, cadence, mode });
532
+ const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
533
+ ? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
534
+ : buildCandidates(rows || [], { now, cadence, mode });
464
535
  if (cadence === 'legacy' && !targetDays.length) {
465
536
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
466
537
  return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
@@ -498,7 +569,7 @@ async function runOutreachSweep({
498
569
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
499
570
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
500
571
  const brief = await enrichBrief(candidate);
501
- if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
572
+ if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
502
573
  counts.rejected++;
503
574
  rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
504
575
  return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
@@ -549,12 +620,13 @@ async function runOutreachSweep({
549
620
  });
550
621
 
551
622
  logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
552
- return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
623
+ return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
553
624
  }
554
625
 
555
626
  module.exports = {
556
627
  runOutreachSweep,
557
628
  buildCandidates,
629
+ buildScopeCandidates,
558
630
  rejectionReason,
559
631
  priorSweepReason,
560
632
  TARGET_DAYS_BY_WEEKDAY,
@@ -566,4 +638,5 @@ module.exports = {
566
638
  VIEW,
567
639
  F,
568
640
  AUDIT,
641
+ sweepClaims,
569
642
  };
package/lib/index.d.ts CHANGED
@@ -501,6 +501,7 @@ declare module '@peopl-health/nexus' {
501
501
  ran: boolean;
502
502
  weekday: number;
503
503
  mode?: 'continuity' | 'monday_triage_recovery';
504
+ source?: 'weekly' | 'patient_scope';
504
505
  targetDays?: string[];
505
506
  dryRun?: boolean;
506
507
  counts: Record<string, number>;
@@ -520,6 +521,8 @@ declare module '@peopl-health/nexus' {
520
521
  mode?: 'continuity' | 'monday_triage_recovery';
521
522
  /** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
522
523
  cadence?: 'daily' | 'legacy';
524
+ /** weekly: the triage questionnaire view. patient_scope: the scoped patients outside that view; requires continuity, daily and a patientScope. */
525
+ source?: 'weekly' | 'patient_scope';
523
526
  }): Promise<OutreachSweepResult>;
524
527
 
525
528
  export const routes: any;
@@ -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.12645",
3
+ "version": "5.70.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",