@peopl-health/nexus 5.68.6 → 5.68.8

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.
@@ -98,6 +98,7 @@ const draftSchema = new mongoose.Schema({
98
98
  missingCharacterization: { type: [String], default: undefined },
99
99
  suggestCall: { type: suggestCallSchema, default: undefined },
100
100
  sectionsUnavailable: { type: [String], default: undefined },
101
+ snapshotAt: { type: Date, default: undefined },
101
102
  }, { timestamps: { createdAt: true, updatedAt: false } });
102
103
 
103
104
  const doctorHandoffSchema = new mongoose.Schema({
@@ -44,7 +44,7 @@ const AUDIT_SOURCE = 'escalation_review';
44
44
  const RECENT_REVIEW_LIMIT = 20;
45
45
  const TIMELINE_LIMIT = 500;
46
46
  const MAX_REVIEW_UPDATES = 50;
47
- const DRAFT_DEBOUNCE_MS = 60 * 1000;
47
+ const DRAFT_DEBOUNCE_MS = 20 * 1000;
48
48
  const DRAFT_OPERATOR = 'bot:escalationDraft';
49
49
  const DIRECT_CHAT_ATTEMPTS = 3;
50
50
  const RECHECK_DELAY_MS = 60 * 1000;
@@ -507,6 +507,25 @@ async function recordReviewUpdate({ code, update, now = new Date(), settleInBack
507
507
  }
508
508
  }
509
509
 
510
+ const STALE_INTERRUPTION_NOTES = 'Cerrada automáticamente al llegar una alarma nueva; nadie verificó si la operación interrumpida se completó';
511
+
512
+ async function closeStaleInterruption(EscalationReview, patientCode, now) {
513
+ try {
514
+ const stale = await EscalationReview.findOne({
515
+ patientCode,
516
+ closedAt: null,
517
+ status: { $in: INTERRUPTED_STATUSES },
518
+ updatedAt: { $lte: new Date(now.getTime() - INTERRUPTION_GRACE_MS) },
519
+ }, { updatedAt: 1 }).lean();
520
+ if (!stale) return;
521
+ await closeEscalationReview({
522
+ id: String(stale._id), closedBy: BOT_OPERATOR, interruptionNotes: STALE_INTERRUPTION_NOTES, expectedUpdatedAt: stale.updatedAt,
523
+ });
524
+ } catch (error) {
525
+ logger[error?.statusCode === 409 ? 'warn' : 'error']('[EscalationReview] The interrupted review was not closed before the new alarm', { error: redactedError(error) });
526
+ }
527
+ }
528
+
510
529
  async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
511
530
  try {
512
531
  const patientCode = patientCodeOf(code);
@@ -518,6 +537,7 @@ async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
518
537
  .lean();
519
538
 
520
539
  await ensureActiveReviewIndex(EscalationReview);
540
+ await closeStaleInterruption(EscalationReview, patientCode, now);
521
541
  const joined = await join();
522
542
  if (joined) {
523
543
  await scheduleDoctorAlert(joined, entry, now);
@@ -1078,6 +1098,7 @@ async function settleFollowUp(snapshot, { background = true } = {}) {
1078
1098
  }
1079
1099
 
1080
1100
  async function generateBotDraft({ id, lease = null } = {}) {
1101
+ const startedAt = new Date();
1081
1102
  const review = await loadReview(reviewIdOf(id));
1082
1103
  if (review.closedAt || !DOCUMENTING_STATUSES.includes(review.status)) return { review: null, retry: false };
1083
1104
  const latest = (review.drafts ?? []).at(-1);
@@ -1107,6 +1128,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
1107
1128
  }
1108
1129
 
1109
1130
  const entry = {
1131
+ snapshotAt: startedAt,
1110
1132
  doctorText: withIdentity({ identity, review, doctorText: draft.doctorText }),
1111
1133
  patientMessage: draft.patientMessage,
1112
1134
  writtenBy: DRAFT_OPERATOR,
@@ -1117,13 +1139,16 @@ async function generateBotDraft({ id, lease = null } = {}) {
1117
1139
  ...(draft.suggestCall && { suggestCall: draft.suggestCall }),
1118
1140
  ...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
1119
1141
  };
1120
- const saved = await getEscalationReview().findOneAndUpdate(
1121
- { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: lease, drafts: { $size: (review.drafts ?? []).length } },
1122
- { $push: { drafts: entry }, $set: { status: 'draft_ready', draftDueAt: null, draftFailedAt: null }, $unset: { identityBlock: '' } },
1142
+ const EscalationReview = getEscalationReview();
1143
+ const saved = await EscalationReview.findOneAndUpdate(
1144
+ { _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: { $lte: lease }, drafts: { $size: (review.drafts ?? []).length } },
1145
+ { $push: { drafts: entry }, $set: { status: 'draft_ready', draftFailedAt: null }, $unset: { identityBlock: '' } },
1123
1146
  { new: true },
1124
1147
  ).lean();
1125
- if (saved) await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1126
- return { review: saved, retry: false };
1148
+ if (!saved) return { review: null, retry: false };
1149
+ const released = await EscalationReview.updateOne({ _id: review._id, draftDueAt: lease }, { $set: { draftDueAt: null } }, BOOKKEEPING);
1150
+ await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
1151
+ return { review: released.matchedCount ? { ...saved, draftDueAt: null } : saved, retry: false };
1127
1152
  }
1128
1153
 
1129
1154
  async function noteFactsContext(review) {
@@ -1341,7 +1366,7 @@ async function handOffToDoctor({ id, mode, physicianEmail = null, sentBy, reques
1341
1366
  if (!approver) throw httpError(403, 'The physician is not an escalation review approver');
1342
1367
  }
1343
1368
 
1344
- const draftedAt = draft.createdAt ? new Date(draft.createdAt).getTime() : null;
1369
+ const draftedAt = draftTimeOf(draft) ? new Date(draftTimeOf(draft)).getTime() : null;
1345
1370
  if (draftedAt && (review.alarms ?? []).some((alarm) => new Date(alarm.raisedAt).getTime() > draftedAt)) {
1346
1371
  throw httpError(409, 'A new alarm arrived after this draft was written; review it and save a new draft before sending it to the doctor');
1347
1372
  }
@@ -1437,7 +1462,7 @@ async function recordDoctorDecision({
1437
1462
  // Truthiness, not != null: an empty textarea is a decline with nothing to say.
1438
1463
  if (decision === 'needs_intervention' && finalText) approvedText = requiredText(finalText, 'finalText');
1439
1464
 
1440
- if (APPROVALS.includes(decision) && arrivedAfter(review, draft.createdAt)) {
1465
+ if (APPROVALS.includes(decision) && arrivedAfter(review, draftTimeOf(draft))) {
1441
1466
  if (review.status === 'awaiting_doctor') {
1442
1467
  const redraft = draft.writtenBy === DRAFT_OPERATOR ? { draftDueAt: new Date(), draftAttempts: 0 } : {};
1443
1468
  const returned = await updateReview(reviewId, ['awaiting_doctor'], { $set: { status: 'documenting', ...redraft } }, {
@@ -1552,6 +1577,8 @@ const latestUpdateGuard = (review) => {
1552
1577
  return { updates: { $size: updates.length }, [`updates.${updates.length - 1}.turnId`]: updates.at(-1).turnId };
1553
1578
  };
1554
1579
 
1580
+ const draftTimeOf = (draft) => draft.snapshotAt ?? draft.createdAt;
1581
+
1555
1582
  const arrivedAfter = (review, since) => Boolean(since) && [
1556
1583
  ...(review.alarms ?? []).map((alarm) => alarm.raisedAt),
1557
1584
  ...(review.updates ?? []).map((update) => update.at),
@@ -6,7 +6,7 @@ const { getEscalationReview } = require('../models/escalationReviewModel');
6
6
  const { BOOKKEEPING, DOCUMENTING_STATUSES, generateBotDraft } = require('./escalationReviewService');
7
7
 
8
8
  const SWEEP_LIMIT = 1;
9
- const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
9
+ const DEFAULT_SWEEP_INTERVAL_MS = 10 * 1000;
10
10
  const RETRY_DELAY_MS = 5 * 60 * 1000;
11
11
  const MAX_DRAFT_ATTEMPTS = 3;
12
12
  const LEASE_MS = 10 * 60 * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.68.6",
3
+ "version": "5.68.8",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",