@peopl-health/nexus 5.68.0-dev.12084 → 5.68.0-dev.12126

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.
@@ -49,7 +49,6 @@ const REMINDER_TYPE_TO_AIRTABLE = {
49
49
  const PENDING_ROW_FILTER = 'IF({is_active}, "true", "false") = "true", IF({is_sent}, "true", "false") = "false"';
50
50
  const ROUTINE_OUTREACH_FILTER = 'OR({type} = "seguimiento", LEFT({schedule_id}, 9) = "recovery_")';
51
51
  const RECOVERY_FILTER = 'LEFT({schedule_id}, 9) = "recovery_"';
52
- // The fire trigger opens 5 min either side of reminder_date; a row still pending after that was not sent.
53
52
  const FIRE_TRIGGER_GRACE_MS = 10 * 60 * 1000;
54
53
 
55
54
  // intake.source only offers self|doctor; team_relay carries "según médico" authority.
@@ -435,8 +434,6 @@ async function hasPendingReminderForPatient({ code, withinMinutes }) {
435
434
  return Array.isArray(rows) && rows.length > 0;
436
435
  }
437
436
 
438
- // A row whose fire time passed unsent was skipped and is never re-triggered, and one past its expiry
439
- // can never fire; neither is a plan the agent or the create guard should see.
440
437
  async function readPendingFollowUpsForPatient({ code, now = new Date() }) {
441
438
  if (!code) return [];
442
439
  const rows = await airtableService.getRecordByFilter(
@@ -460,23 +457,25 @@ function isAtOrBefore(when, now) {
460
457
  return Number.isFinite(at) && at <= new Date(now).getTime();
461
458
  }
462
459
 
463
- /*
464
- * Another check-in about to reach this patient: a net's patient check-in or another routine
465
- * follow-up due within `withinMinutes`. The lower bound is the trigger's own 5-minute lead, so two
466
- * rows firing together see each other; an unsent row older than that, or expired, was missed and
467
- * is not coming. Patient-requested reminders are left out; they are what she asked for.
468
- */
469
- async function hasCheckInNear({ code, withinMinutes, excludeReminderId }) {
460
+ async function hasCheckInNear({ code, withinMinutes, excludeReminderId, fireAt = null }) {
470
461
  const window = Number(withinMinutes);
471
462
  if (!code || !Number.isFinite(window) || window <= 0) return false;
463
+ const pending = `AND(${PENDING_ROW_FILTER}, DATETIME_DIFF({reminder_date}, NOW(), 'minutes') >= -5, DATETIME_DIFF({reminder_date}, NOW(), 'minutes') <= ${window}, OR({expires_at} = BLANK(), IS_AFTER({expires_at}, NOW())))`;
464
+ const sent = `AND({is_sent}, DATETIME_DIFF(NOW(), {sent_at}, 'minutes') <= ${window})`;
472
465
  const rows = await airtableService.getRecordByFilter(
473
466
  Monitoreo_ID,
474
467
  'reminder',
475
- `AND(${fieldEquals('whatsapp_id', code)}, ${PENDING_ROW_FILTER}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}), DATETIME_DIFF({reminder_date}, NOW(), 'minutes') >= -5, DATETIME_DIFF({reminder_date}, NOW(), 'minutes') <= ${window}, OR({expires_at} = BLANK(), IS_AFTER({expires_at}, NOW())))`,
468
+ `AND(${fieldEquals('whatsapp_id', code)}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}), OR(${pending}, ${sent}))`,
476
469
  null,
477
- ['schedule_id'],
470
+ ['schedule_id', 'reminder_date', 'is_sent'],
478
471
  );
479
- return Array.isArray(rows) && rows.length > 0;
472
+ const ownAt = new Date(fireAt).getTime() || 0;
473
+ return (rows || []).some((row) => {
474
+ const id = String(row.schedule_id || '');
475
+ if (row.is_sent || id.endsWith('-patient')) return true;
476
+ const at = new Date(row.reminder_date).getTime() || 0;
477
+ return at < ownAt || (at === ownAt && id < excludeReminderId);
478
+ });
480
479
  }
481
480
 
482
481
  async function readPendingReminderForPatient({ code, reminderId }) {
@@ -499,7 +498,6 @@ async function readPendingReminderForPatient({ code, reminderId }) {
499
498
  } : null;
500
499
  }
501
500
 
502
- // Patient activity retires only turn-loss recovery rows; a seguimiento stays for the agent to keep, update or cancel.
503
501
  async function cancelPendingFollowUps({ code, trace = null, reason = 'patient_replied', recoveryOnly = false }) {
504
502
  return standDownRow({
505
503
  code,
@@ -20,7 +20,7 @@ const { normalizePayload: normalize, registerInteractiveRoute, unregisterInterac
20
20
  const { getMessages } = require('../../services/messageService');
21
21
 
22
22
  const { WriteClaimStore } = require('./writeClaimStore');
23
- const { OPS_ALERTS, recordPausedThreadMessage, recordUnresolvedRequest } = require('./clinicalAirtableService');
23
+ const { OPS_ALERTS, cancelPendingFollowUps, recordPausedThreadMessage, recordUnresolvedRequest } = require('./clinicalAirtableService');
24
24
  const { anyoneRepliedSince, humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
25
25
 
26
26
  const getMessaging = () => require('../../core/NexusMessaging');
@@ -219,6 +219,10 @@ async function applyChoice(code, choice) {
219
219
  const queued = await fileUrgentRequest(code, choice);
220
220
  if (!queued) return { queued, pledge: null };
221
221
 
222
+ await cancelPendingFollowUps({ code, reason: 'safety_net_request' }).catch((error) =>
223
+ logger.error('[PausedSafetyNet] Pending follow-ups were not stood down', { code, error: error.message })
224
+ );
225
+
222
226
  try {
223
227
  return { queued, pledge: await openPledge({ code }) };
224
228
  } catch (error) {
@@ -608,7 +608,6 @@ class NexusMessaging {
608
608
  if (chatId) {
609
609
  // This is the common ingress for text, media, buttons, lists, and flows. Retire turn-loss
610
610
  // recovery here, before specialized handlers split off, so all patient activity counts.
611
- // A seguimiento survives: the next turn shows it to the agent, who keeps, updates or cancels it.
612
611
  await cancelPendingFollowUps({ code: chatId, recoveryOnly: true }).catch(error =>
613
612
  logger.error('[processIncomingMessage] Follow-up stand-down failed', { code: chatId, error: error.message })
614
613
  );
@@ -1088,11 +1087,7 @@ class NexusMessaging {
1088
1087
  return result;
1089
1088
  }
1090
1089
 
1091
- // An open net alone does not hold a seguimiento: the two ask different things. Hold only when
1092
- // another check-in is about to reach the patient. Same-day activity does not hold an agent
1093
- // seguimiento: the agent saw it on every turn since and kept it. The fire trigger does not come
1094
- // back for a skipped row, so a skip retires it with its reason instead of leaving it armed.
1095
- const holdReason = await hasCheckInNear({ code, withinMinutes: FOLLOW_UP_SPACING_MIN, excludeReminderId: reminderId })
1090
+ const holdReason = await hasCheckInNear({ code, withinMinutes: FOLLOW_UP_SPACING_MIN, excludeReminderId: reminderId, fireAt: plan.fireAt })
1096
1091
  ? 'check_in_near'
1097
1092
  : await followUpCollisionReason(code, reminderId, startedAt);
1098
1093
  if (holdReason) {
@@ -194,6 +194,7 @@ const escalationReviewSchema = new mongoose.Schema({
194
194
  followUpDecision: { type: Number, default: null },
195
195
  draftDueAt: { type: Date, default: null },
196
196
  draftAttempts: { type: Number, default: 0 },
197
+ draftFailedAt: { type: Date, default: null },
197
198
  doctorAlerts: { type: [doctorAlertSchema], default: [] },
198
199
  doctorAlertDueAt: { type: Date, default: null },
199
200
  doctorAlertKind: { type: String, enum: ['initial', 'realert', null], default: null },
@@ -1118,7 +1118,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
1118
1118
  };
1119
1119
  const saved = await getEscalationReview().findOneAndUpdate(
1120
1120
  { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: lease, drafts: { $size: (review.drafts ?? []).length } },
1121
- { $push: { drafts: entry }, $set: { status: 'draft_ready', draftDueAt: null }, $unset: { identityBlock: '' } },
1121
+ { $push: { drafts: entry }, $set: { status: 'draft_ready', draftDueAt: null, draftFailedAt: null }, $unset: { identityBlock: '' } },
1122
1122
  { new: true },
1123
1123
  ).lean();
1124
1124
  if (saved) await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
@@ -1811,6 +1811,7 @@ async function syncEscalationReviewCase({ id, syncedBy, requestId = null } = {})
1811
1811
 
1812
1812
  module.exports = {
1813
1813
  BOOKKEEPING,
1814
+ DOCUMENTING_STATUSES,
1814
1815
  PRE_DECISION_STATUSES,
1815
1816
  _resetEscalationReviewIndexes,
1816
1817
  addCallLog,
@@ -3,7 +3,7 @@ const { createIntervalSweeper } = require('../utils/intervalSweeper');
3
3
  const { withTimeout } = require('../utils/timeoutUtils');
4
4
  const { getEscalationReview } = require('../models/escalationReviewModel');
5
5
 
6
- const { BOOKKEEPING, generateBotDraft } = require('./escalationReviewService');
6
+ const { BOOKKEEPING, DOCUMENTING_STATUSES, generateBotDraft } = require('./escalationReviewService');
7
7
 
8
8
  const SWEEP_LIMIT = 1;
9
9
  const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
@@ -34,6 +34,14 @@ async function draftReview(EscalationReview, { _id, draftDueAt, draftAttempts =
34
34
  escalationReviewId: String(_id), attempts: draftAttempts + 1,
35
35
  });
36
36
  }
37
+ if (retry && !willRetry) {
38
+ const marked = await EscalationReview.updateOne(
39
+ { _id, draftDueAt: lease, closedAt: null, status: { $in: DOCUMENTING_STATUSES } },
40
+ { $set: { draftDueAt: null, draftFailedAt: new Date() } },
41
+ BOOKKEEPING,
42
+ );
43
+ if (marked.matchedCount) return false;
44
+ }
37
45
  await EscalationReview.updateOne(
38
46
  { _id, draftDueAt: lease },
39
47
  willRetry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.68.0-dev.12084",
3
+ "version": "5.68.0-dev.12126",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",