@peopl-health/nexus 6.3.0-dev.18429 → 6.3.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.
package/README.md CHANGED
@@ -169,6 +169,11 @@ The route helpers intentionally leave authentication to the host application. Mo
169
169
  tenant authentication and authorization middleware before exposing them; patient routes return
170
170
  protected health information and must never be public.
171
171
 
172
+ `POST /api/message/send`, `/send-bulk` and `/send-bulk-airtable` accept an optional `expiresAt`
173
+ (ISO 8601 with an offset or `Z`) for messages that only matter until a moment, such as a session
174
+ reminder. Once it has passed the send is refused with `status: 'expired'`, a scheduled one is
175
+ cancelled, and a failed one is never recovered, retried, replayed or nudged.
176
+
172
177
  ## Support-session routing
173
178
 
174
179
  `supportCandidateService` is exported for consumers that coordinate first-session
@@ -1,6 +1,7 @@
1
1
  const { createHash } = require('crypto');
2
2
  const { isShadowRun } = require('../helpers/shadowHelper');
3
3
  const { decodeUnicodeEscapes } = require('../../utils/sanitizerUtils');
4
+ const { isAtOrBefore } = require('../../utils/dateUtils');
4
5
 
5
6
  const { Monitoreo_ID, Estado_General_ID, Symptoms_ID } = require('../../config/airtableConfig');
6
7
  const runtimeConfig = require('../../config/runtimeConfig');
@@ -483,11 +484,6 @@ async function readPendingFollowUpsForPatient({ code, now = new Date() }) {
483
484
  })).filter((plan) => !isAtOrBefore(plan.expiresAt, now) && !isAtOrBefore(plan.fireAt, new Date(now).getTime() - FIRE_TRIGGER_GRACE_MS));
484
485
  }
485
486
 
486
- function isAtOrBefore(when, now) {
487
- const at = when ? new Date(when).getTime() : NaN;
488
- return Number.isFinite(at) && at <= new Date(now).getTime();
489
- }
490
-
491
487
  async function hasCheckInNear({ code, withinMinutes, excludeReminderId, fireAt = null }) {
492
488
  const window = Number(withinMinutes);
493
489
  if (!code || !Number.isFinite(window) || window <= 0) return false;
@@ -9,7 +9,7 @@ const EMPTY_OUTBOUND_FALLBACK = 'Recibí tu mensaje. Si quieres contarme más o
9
9
  const definition = {
10
10
  name: 'DeliverPatientMessage',
11
11
  replayArgs: ['message_text', 'purpose'],
12
- description: '**Does:** Delivers the final, patient-facing message in Spanish — the only tool that produces text the patient sees. Call exactly ONCE as the last action of every CHAT turn.\n\n**Required inputs:** `message_text` (the message itself, in Spanish); `purpose` (the conversational purpose); `reasoning` (one short sentence, never shown to the patient, explaining why this is the right message for this turn).\n\n**When to call:** at the end of every chat turn — once. Every substantive inbound must first complete an intake/classification decision. When that decision extracts clinical mentions, code enforces: processed risk through `getRouterContextBundle` (or its dedicated recovery) → `submitRoutingDecision` → this tool. A committed non-clinical terminal workflow may deliver without clinical risk/routing. A rejected call returns the missing step in `data.recovery`; perform it and regenerate the message.\n\n**When NOT to call:**\n- More than once per turn — only ONE patient-facing message per turn.\n- Before the required classification or clinical chain is complete. A rejected call does not set outbound text.\n\n**When the patient asks what the care team answered:** say where the case stands from `turn_focus.team_cases` (load `getRouterContextBundle` first if you have not this turn). Never add a time, and never say the team already reviewed or saw something unless the status is one where the team answered (`Esperando paciente`, `Indicaciones dadas`, `En vigilancia`).\n\n**Returns:** a RECORD, not a send — `{purpose, recorded, delivered:false, still_active, means}`. The reply is stored as the outbound of this turn and leaves only when the turn finishes as the active run; a newer patient message drops it and `still_active` is `false`. Never treat the patient as informed because this call succeeded.\n\n**Side effects:** records the final patient-facing message on the turn trace; delivery to the patient is handled downstream.',
12
+ description: '**Does:** Delivers the final, patient-facing message in Spanish — the only tool that produces text the patient sees. Call exactly ONCE as the last action of every CHAT turn.\n\n**Required inputs:** `message_text` (the message itself, in Spanish); `purpose` (the conversational purpose); `reasoning` (one short sentence, never shown to the patient, explaining why this is the right message for this turn).\n\n**When to call:** at the end of every chat turn — once. Every substantive inbound must first complete an intake/classification decision. When that decision extracts clinical mentions, code enforces: processed risk through `getRouterContextBundle` (or its dedicated recovery) → `submitRoutingDecision` → this tool. A committed non-clinical terminal workflow may deliver without clinical risk/routing. A rejected call returns the missing step in `data.recovery`; perform it and regenerate the message.\n\n**When NOT to call:**\n- More than once per turn — only ONE patient-facing message per turn.\n- Before the required classification or clinical chain is complete. A rejected call does not set outbound text.\n\n**When the patient asks what the care team answered:** say where the case stands from `turn_focus.team_cases` (load `getRouterContextBundle` first if you have not this turn). Never add a time, and never say the team already reviewed or saw something unless the status is one where the team answered (`Esperando paciente`, `Indicaciones dadas`, `En vigilancia`).\n\n**When you relay what a doctor indicated:** name the doctor the team instruction names, even when it is not the `Médico tratante` of the clinical context. Saying the treating doctor reviewed the case when another doctor did tells the patient something false.\n\n**Returns:** a RECORD, not a send — `{purpose, recorded, delivered:false, still_active, means}`. The reply is stored as the outbound of this turn and leaves only when the turn finishes as the active run; a newer patient message drops it and `still_active` is `false`. Never treat the patient as informed because this call succeeded.\n\n**Side effects:** records the final patient-facing message on the turn trace; delivery to the patient is handled downstream.',
13
13
  strict: false,
14
14
  parameters: {
15
15
  type: 'object',
@@ -1,5 +1,6 @@
1
1
  const moment = require('moment-timezone');
2
2
  const mongoose = require('mongoose');
3
+ const { z } = require('zod');
3
4
 
4
5
  const { logger } = require('../utils/logger');
5
6
  const { withTimeout } = require('../utils/timeoutUtils');
@@ -14,7 +15,7 @@ const { CAREGIVER_ROLE, resolveCaregiver } = require('../helpers/caregiverHelper
14
15
 
15
16
  const { getRecordByFilter } = require('../services/airtableService');
16
17
  const { getMessages } = require('../services/messageService');
17
- const { recordDirectIndication } = require('../services/escalationReviewService');
18
+ const { recordDirectIndication, recordPatientSend } = require('../services/escalationReviewService');
18
19
  const { resolveThreadCode } = require('../services/threadService');
19
20
 
20
21
  const INDICATION_WAIT_MS = 5000;
@@ -41,6 +42,14 @@ const _validateMessagingDeps = (res, requireAirtable = false) => {
41
42
  return true;
42
43
  };
43
44
 
45
+ const expiresAtSchema = z.iso.datetime({ offset: true }).nullable();
46
+
47
+ const _validateExpiresAt = (res, expiresAt) => {
48
+ if (expiresAtSchema.safeParse(expiresAt).success) return true;
49
+ res.status(400).json({ success: false, error: 'expiresAt must be an ISO 8601 datetime with an offset or Z' });
50
+ return false;
51
+ };
52
+
44
53
  const _pickMessageId = (result, fallback) =>
45
54
  result?.sid || result?.messageId || result?.id || result?._id ||
46
55
  fallback?.wa_id || fallback?.sid || fallback?.id || fallback?._id?.toString() || null;
@@ -75,7 +84,8 @@ const sendMessageController = async (req, res) => {
75
84
  triggeredBy = null,
76
85
  idempotencyKey = null,
77
86
  contactCode = null,
78
- asIndication = false
87
+ asIndication = false,
88
+ expiresAt = null
79
89
  } = req.body || {};
80
90
 
81
91
  if (!ensureWhatsAppFormat(code)) {
@@ -86,6 +96,8 @@ const sendMessageController = async (req, res) => {
86
96
  return res.status(400).json({ success: false, error: 'message, fileUrl or contentSid is required' });
87
97
  }
88
98
 
99
+ if (!_validateExpiresAt(res, expiresAt)) return;
100
+
89
101
  try {
90
102
  const formattedCode = await resolveThreadCode(ensureWhatsAppFormat(code));
91
103
  let contact = {};
@@ -107,8 +119,11 @@ const sendMessageController = async (req, res) => {
107
119
  const result = await sendMessage({
108
120
  fileUrl, body: message, fileType, contentSid, hidePreview,
109
121
  variables: finalVariables, code: formattedCode,
110
- frontendId, triggeredBy, idempotencyKey, ...contact
122
+ frontendId, triggeredBy, idempotencyKey, expiresAt, ...contact
111
123
  });
124
+ if (result?.success && message && !contentSid && !contact.contactCode) {
125
+ recordPatientSend({ code: formattedCode, channel: 'staff_message', sentBy: triggeredBy, text: message, messageId: _pickMessageId(result, null), deferred: result?.deferred === true || result?.preemptive === true });
126
+ }
112
127
  if (asIndication === true && result?.success && triggeredBy && message && !contentSid && !contact.contactCode) {
113
128
  await withTimeout(recordDirectIndication({
114
129
  code: formattedCode,
@@ -138,7 +153,8 @@ const sendMessageController = async (req, res) => {
138
153
  timeZone: timeZone || null,
139
154
  sendTime: moment.tz(sendTime, timeZone) + 2500,
140
155
  code: formattedCode,
141
- triggeredBy
156
+ triggeredBy,
157
+ expiresAt
142
158
  };
143
159
 
144
160
  const { result, saved } = await _sendAndPersist(payload);
@@ -163,9 +179,12 @@ const sendBulkMessageController = async (req, res) => {
163
179
  hidePreview = false,
164
180
  contentSid = null,
165
181
  variables = null,
166
- triggeredBy = null
182
+ triggeredBy = null,
183
+ expiresAt = null
167
184
  } = req.body || {};
168
185
 
186
+ if (!_validateExpiresAt(res, expiresAt)) return;
187
+
169
188
  try {
170
189
  const isFlowTemplate = contentSid && await FlowRouting.findOne({ contentSid }).lean();
171
190
 
@@ -179,7 +198,8 @@ const sendBulkMessageController = async (req, res) => {
179
198
  variables: isFlowTemplate ? await ensureFlowTokenInVariables(contentSid, variables) : variables,
180
199
  timeZone: timeZone || null,
181
200
  code: ensureWhatsAppFormat(recipient),
182
- triggeredBy
201
+ triggeredBy,
202
+ expiresAt
183
203
  }
184
204
  });
185
205
  extraDelay += Math.floor(Math.random() * 5001) + 5000;
@@ -208,8 +228,12 @@ const sendBulkMessageAirtableController = async (req, res) => {
208
228
  contentSid = null,
209
229
  condition = '1',
210
230
  variables = null,
211
- triggeredBy = null
231
+ triggeredBy = null,
232
+ expiresAt = null
212
233
  } = req.body || {};
234
+
235
+ if (!_validateExpiresAt(res, expiresAt)) return;
236
+
213
237
  const sendMoment = sendTime ? moment.tz(sendTime, timeZone) + 20000 : new Date();
214
238
 
215
239
  try {
@@ -241,7 +265,8 @@ const sendBulkMessageAirtableController = async (req, res) => {
241
265
  timeZone: timeZone || null,
242
266
  sendTime: new Date(sendMoment + extraDelay),
243
267
  code,
244
- triggeredBy
268
+ triggeredBy,
269
+ expiresAt
245
270
  });
246
271
  extraDelay += Math.floor(Math.random() * 5001) + 5000;
247
272
  }
@@ -12,6 +12,7 @@ const { logger } = require('../utils/logger');
12
12
 
13
13
  const { createEvent, safeEmit } = require('../utils/eventUtils');
14
14
  const { withTimeout } = require('../utils/timeoutUtils');
15
+ const { isAtOrBefore } = require('../utils/dateUtils');
15
16
  const { Message } = require('../models/messageModel');
16
17
  const { Thread } = require('../models/threadModel');
17
18
 
@@ -113,6 +114,7 @@ function createDefaultLlmMonitor(options, loader = loadDefaultLlmMonitor) {
113
114
  * Core messaging orchestrator for providers, storage, and assistant processing.
114
115
  */
115
116
  const NOT_SENT_DECEASED = { success: false, messageId: null, status: 'patient_deceased', delivered: false, deferred: false };
117
+ const NOT_SENT_EXPIRED = { success: false, messageId: null, status: 'expired', delivered: false, deferred: false };
116
118
 
117
119
  class NexusMessaging {
118
120
  constructor(config = {}) {
@@ -389,6 +391,7 @@ class NexusMessaging {
389
391
  */
390
392
 
391
393
  async sendMessage(messageData) {
394
+ if (isAtOrBefore(messageData.expiresAt, Date.now())) return NOT_SENT_EXPIRED;
392
395
  messageData = { ...messageData, code: await resolveThreadCode(messageData.code) };
393
396
  // Only an operator writing now still reaches a deceased patient's phone; automations and scheduled sends do not.
394
397
  const operatorNow = isStaffSend(messageData.triggeredBy) && !String(messageData.idempotencyKey || '').startsWith('scheduled:');
@@ -476,7 +479,8 @@ class NexusMessaging {
476
479
  if (messageData.parentMessageId) {
477
480
  const [found] = await getMessages({ _id: messageData.parentMessageId }, { limit: 1 });
478
481
  if (!found) return { success: false, delivered: false, deferred: true };
479
- await triggerTemplateRecovery(found, { source: 'preemptive' });
482
+ const queued = await triggerTemplateRecovery(found, { source: 'preemptive' });
483
+ if (queued?.expired) return { ...NOT_SENT_EXPIRED, messageId: String(found._id) };
480
484
  return { success: true, messageId: String(found._id), status: 'queued', preemptive: true, delivered: false, deferred: true };
481
485
  }
482
486
  const saveFn = this.messageStorage.savePendingMessage.bind(this.messageStorage);
@@ -492,7 +496,10 @@ class NexusMessaging {
492
496
  // triggerTemplateRecovery swallows its own errors and returns undefined. Reporting "queued"
493
497
  // regardless is how a template that was never created still counts as an outreach, and the
494
498
  // weekly guard then stops us ever trying again.
495
- const queued = await triggerTemplateRecovery({ _id, body, numero: messageData.code, media: messageData.media }, { source: 'preemptive' });
499
+ const queued = await triggerTemplateRecovery({ _id, body, numero: messageData.code, media: messageData.media, expiresAt: messageData.expiresAt }, { source: 'preemptive' });
500
+ if (queued?.expired) {
501
+ return { ...NOT_SENT_EXPIRED, messageId: String(_id) };
502
+ }
496
503
  if (!queued?.deferred) {
497
504
  logger.error('[NexusMessaging] template recovery setup failed; message not queued', { code: messageData.code, messageId: String(_id) });
498
505
  return { success: false, messageId: String(_id), status: 'failed', preemptive: true, delivered: false, deferred: false };
@@ -1079,6 +1086,8 @@ class NexusMessaging {
1079
1086
  };
1080
1087
 
1081
1088
  let runResult = null;
1089
+ let auditRowId = null;
1090
+ let sendThrew = false;
1082
1091
  const executeTurn = () => this._executeWithPipeline(code, 'outreach', 'queue',
1083
1092
  async (preProcessResult, shouldContinue, runId) => this.assistantProcessor.process({
1084
1093
  code,
@@ -1099,7 +1108,7 @@ class NexusMessaging {
1099
1108
  runId,
1100
1109
  }
1101
1110
  }),
1102
- { sendResponse: !dryRun });
1111
+ { sendResponse: !dryRun, onSendSettled: async (settled) => { sendThrew = settled === 'threw'; } });
1103
1112
 
1104
1113
  if (dryRun) {
1105
1114
  runResult = await runInShadowScope(executeTurn);
@@ -1108,6 +1117,7 @@ class NexusMessaging {
1108
1117
  messageData,
1109
1118
  this._saveMessageFn,
1110
1119
  async ({ _id }) => {
1120
+ auditRowId = _id;
1111
1121
  runResult = await executeTurn();
1112
1122
  if (_id) {
1113
1123
  try {
@@ -1121,6 +1131,20 @@ class NexusMessaging {
1121
1131
  );
1122
1132
  }
1123
1133
 
1134
+ const result = this._outreachResult(code, runResult, { dryRun, firstName });
1135
+ if (auditRowId) {
1136
+ // A send that threw may have reached the patient in part.
1137
+ const outcome = { decision: result.decision, holdReason: result.holdReason, nextCheckAt: result.nextCheckAt, sent: sendThrew ? null : Boolean(result.accepted), traceId: result.traceId };
1138
+ try {
1139
+ await updateMessage({ _id: auditRowId }, { $set: { 'raw.outcome': outcome } });
1140
+ } catch (err) {
1141
+ logger.error('[processOutreach] Could not record the outreach outcome', { error: err.message, code });
1142
+ }
1143
+ }
1144
+ return result;
1145
+ }
1146
+
1147
+ _outreachResult(code, runResult, { dryRun, firstName }) {
1124
1148
  // The pipeline returns null when the turn was superseded or a preProcess hook skipped it. That is
1125
1149
  // not the agent choosing to stay quiet, and counting it as a hold would flatter the hold rate.
1126
1150
  if (!runResult) {
@@ -1345,6 +1369,8 @@ class NexusMessaging {
1345
1369
  executeWithPipeline: this._executeWithPipeline.bind(this),
1346
1370
  assistantProcessor: this.assistantProcessor,
1347
1371
  getAbortSignal: this.batchingManager.getAbortSignal.bind(this.batchingManager),
1372
+ // Required lazily: escalationReviewService reaches back into this module.
1373
+ recordPatientSend: (send) => require('../services/escalationReviewService').recordPatientSend(send),
1348
1374
  });
1349
1375
  }
1350
1376
 
@@ -2,6 +2,7 @@ const runtimeConfig = require('../config/runtimeConfig');
2
2
 
3
3
  const { logger } = require('../utils/logger');
4
4
  const { createEvent, safeEmit } = require('../utils/eventUtils');
5
+ const { isAtOrBefore } = require('../utils/dateUtils');
5
6
 
6
7
  const { Message } = require('../models/messageModel');
7
8
  const { ScheduledMessage } = require('../models/agendaMessageModel');
@@ -111,6 +112,11 @@ async function handleRetryableChannelError(messageDoc, twilioSid, errorCode) {
111
112
  const priorAttempts = await DeliveryAttempt.countDocuments({ messageId, kind: 'template' });
112
113
  const delay = retryDelays()[priorAttempts - 1];
113
114
 
115
+ if (isAtOrBefore(messageDoc.expiresAt, new Date(Date.now() + (delay ?? 0)))) {
116
+ logger.info('[ChannelRetry] Message expires before the retry, skipping', { ...logCtx, expiresAt: messageDoc.expiresAt });
117
+ return { retried: false, reason: 'expires_before_retry' };
118
+ }
119
+
114
120
  if (delay === undefined) {
115
121
  alertUndeliverable({ ...logCtx, contentSid: attempt.contentSid, attempts: priorAttempts, reason: 'max_attempts' });
116
122
  return { retried: false, reason: 'max_attempts' };
@@ -133,7 +139,8 @@ async function handleRetryableChannelError(messageDoc, twilioSid, errorCode) {
133
139
  variables,
134
140
  sendTime,
135
141
  parentMessageId: messageId,
136
- triggeredBy: 'channel_retry'
142
+ triggeredBy: 'channel_retry',
143
+ expiresAt: messageDoc.expiresAt
137
144
  });
138
145
 
139
146
  try {
@@ -1,5 +1,6 @@
1
1
  const { generatePresignedUrl } = require('../config/awsConfig');
2
2
  const { logger } = require('../utils/logger');
3
+ const { isAtOrBefore } = require('../utils/dateUtils');
3
4
  const { Message } = require('../models/messageModel');
4
5
  const { Template: TemplateModel } = require('../models/templateModel');
5
6
  const { Template } = require('../templates/templateStructure');
@@ -32,6 +33,24 @@ async function resolveRecoveryBody(messageDoc) {
32
33
  return decoded || messageDoc.body;
33
34
  }
34
35
 
36
+ async function closeExpiredRecovery(messageDoc) {
37
+ const claim = await Message.updateOne(
38
+ { _id: messageDoc._id, 'statusInfo.recoveryReleasedAt': null, 'statusInfo.recoverySentAt': null },
39
+ {
40
+ $set: {
41
+ 'statusInfo.recoveryReleasedAt': new Date(),
42
+ 'statusInfo.status': 'failed',
43
+ 'statusInfo.errorCode': 'expired',
44
+ 'statusInfo.errorMessage': 'Expired before it could be delivered',
45
+ 'statusInfo.updatedAt': new Date()
46
+ }
47
+ }
48
+ );
49
+ if (!claim.modifiedCount && !claim.nModified) return;
50
+ logger.info('[TemplateRecovery] Expired before delivery, recovery closed', { messageDocId: String(messageDoc._id), expiresAt: messageDoc.expiresAt });
51
+ await cleanupRecoveryTemplate(messageDoc);
52
+ }
53
+
35
54
  async function triggerTemplateRecovery(messageDoc, { source = 'reactive', messageSid = null } = {}) {
36
55
  try {
37
56
  if (!messageDoc?.body || !messageDoc?.numero || !messageDoc?._id) return;
@@ -44,6 +63,11 @@ async function triggerTemplateRecovery(messageDoc, { source = 'reactive', messag
44
63
  return;
45
64
  }
46
65
 
66
+ if (isAtOrBefore(messageDoc.expiresAt, Date.now())) {
67
+ await closeExpiredRecovery(messageDoc);
68
+ return { delivered: false, deferred: false, expired: true };
69
+ }
70
+
47
71
  try {
48
72
  const claim = await Message.updateOne(
49
73
  { _id: messageDocId, 'statusInfo.recoveryStartedAt': null },
@@ -172,4 +196,4 @@ async function cleanupRecoveryTemplate(messageDoc) {
172
196
  }
173
197
  }
174
198
 
175
- module.exports = { handle24HourWindowError, triggerTemplateRecovery, cleanupRecoveryTemplate, resolveRecoveryBody, hasUnresolvedPhi };
199
+ module.exports = { handle24HourWindowError, triggerTemplateRecovery, cleanupRecoveryTemplate, resolveRecoveryBody, hasUnresolvedPhi, closeExpiredRecovery };
package/lib/index.d.ts CHANGED
@@ -18,6 +18,7 @@ declare module '@peopl-health/nexus' {
18
18
  fileType?: 'text' | 'image' | 'document' | 'audio' | 'video';
19
19
  contentSid?: string;
20
20
  variables?: Record<string, string>;
21
+ expiresAt?: string | Date;
21
22
  interactive?: InteractiveData;
22
23
  media?: MediaData;
23
24
  command?: CommandData;
@@ -109,6 +109,7 @@ class ScheduledMessageJob extends BaseJob {
109
109
  variables: msg.variables,
110
110
  hidePreview: msg.hidePreview,
111
111
  triggeredBy: msg.triggeredBy,
112
+ expiresAt: msg.expiresAt,
112
113
  processed: true,
113
114
  idempotencyKey: `scheduled:${scheduledMessageId}`,
114
115
  ...(parentMessageId ? { parentMessageId } : {
@@ -144,6 +145,15 @@ class ScheduledMessageJob extends BaseJob {
144
145
  return { success: false, reason: result.status };
145
146
  }
146
147
 
148
+ if (result?.status === 'expired') {
149
+ await ScheduledMessage.updateOne(
150
+ { _id: scheduledMessageId },
151
+ { $set: { status: 'cancelled', errorCode: result.status, errorMessage: 'Expired before it could be sent' } }
152
+ );
153
+ logger.info('[ScheduledMessageJob] Not sent, the message expired before it could be sent', { scheduledMessageId });
154
+ return { success: false, reason: result.status };
155
+ }
156
+
147
157
  if (result?.status === 'outbound_blocked') {
148
158
  await ScheduledMessage.updateOne(
149
159
  { _id: scheduledMessageId },
@@ -2,6 +2,7 @@ const runtimeConfig = require('../config/runtimeConfig');
2
2
 
3
3
  const { logger } = require('../utils/logger');
4
4
  const { withTimeout } = require('../utils/timeoutUtils');
5
+ const { isAtOrBefore } = require('../utils/dateUtils');
5
6
  const { createEvent, safeEmit } = require('../utils/eventUtils');
6
7
  const { Message } = require('../models/messageModel');
7
8
  const { DeliveryAttempt } = require('../models/deliveryAttemptModel');
@@ -9,6 +10,7 @@ const { DeliveryAttempt } = require('../models/deliveryAttemptModel');
9
10
  const { alertUndeliverable } = require('../helpers/channelRetryHelper');
10
11
 
11
12
  const { contactOf } = require('../helpers/caregiverHelper');
13
+ const { closeExpiredRecovery } = require('../helpers/templateRecoveryHelper');
12
14
  const { getMessages } = require('../services/messageService');
13
15
  const { recordUndeliverable } = require('../services/undeliverableService');
14
16
 
@@ -102,6 +104,7 @@ class TemplateApprovalJob extends BaseJob {
102
104
 
103
105
  let provider;
104
106
  let approvalStatus;
107
+ let checkError = null;
105
108
  try {
106
109
  provider = this.requireProvider();
107
110
  const status = await withTimeout(
@@ -111,8 +114,16 @@ class TemplateApprovalJob extends BaseJob {
111
114
  );
112
115
  approvalStatus = status?.approvalRequest?.status?.toUpperCase();
113
116
  } catch (err) {
117
+ checkError = err;
118
+ }
119
+
120
+ if (isAtOrBefore(message.expiresAt, Date.now())) {
121
+ await closeExpiredRecovery(message);
122
+ return { success: false, reason: 'expired' };
123
+ }
124
+ if (checkError) {
114
125
  logger.error('[TemplateApprovalJob] Provider error during approval check, treating as transient', {
115
- templateSid, messageId, attempt, error: err.message
126
+ templateSid, messageId, attempt, error: checkError.message
116
127
  });
117
128
  return await this._rescheduleOrGiveUp({ templateSid, message, originalMessageSid, attempt, reason: 'error' });
118
129
  }
@@ -20,6 +20,7 @@ const scheduledMessageSchema = new mongoose.Schema({
20
20
  wa_id: { type: String, default: null },
21
21
  triggeredBy: { type: String, default: null },
22
22
  parentMessageId: { type: mongoose.Schema.Types.ObjectId, ref: 'Message', default: null },
23
+ expiresAt: { type: Date, default: null },
23
24
  createdAt: { type: Date, default: Date.now }
24
25
  });
25
26
 
@@ -217,6 +217,15 @@ const patientDeliverySchema = new mongoose.Schema({
217
217
  sentBy: { type: String, required: true },
218
218
  }, { timestamps: { createdAt: true, updatedAt: false } });
219
219
 
220
+ const patientSendSchema = new mongoose.Schema({
221
+ channel: { type: String, enum: ['assistant_instruction', 'staff_message'], required: true },
222
+ text: { type: String, required: true },
223
+ messageId: { type: String, default: null },
224
+ deferred: { type: Boolean, default: false },
225
+ sentBy: { type: String, required: true },
226
+ sentAt: { type: Date, required: true },
227
+ }, { _id: false });
228
+
220
229
  const pendingCaseMirrorSchema = new mongoose.Schema({
221
230
  recordId: { type: String, required: true },
222
231
  changes: { type: mongoose.Schema.Types.Mixed, required: true },
@@ -299,6 +308,7 @@ const escalationReviewSchema = new mongoose.Schema({
299
308
  doctorHandoffs: { type: [doctorHandoffSchema], default: [] },
300
309
  decisions: { type: [decisionSchema], default: [] },
301
310
  patientDelivery: { type: patientDeliverySchema, default: null },
311
+ patientSends: { type: [patientSendSchema], default: undefined },
302
312
  pendingCaseMirrors: { type: [pendingCaseMirrorSchema], default: [] },
303
313
  interruptionResolution: { type: interruptionResolutionSchema, default: null },
304
314
  identityBlock: { type: identityBlockSchema, default: null },
@@ -89,7 +89,8 @@ const messageSchema = new mongoose.Schema({
89
89
  turnId: { type: String },
90
90
  processedByTurnId: { type: String },
91
91
  traceId: { type: String },
92
- triggeredBy: { type: String, default: null }
92
+ triggeredBy: { type: String, default: null },
93
+ expiresAt: { type: Date, default: null }
93
94
  }, { timestamps: true });
94
95
 
95
96
  messageSchema.index({ numero: 1, createdAt: -1 });
@@ -75,7 +75,7 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
75
75
  throw new AssistantMessageError('CONVERSATION_UNAVAILABLE');
76
76
  }
77
77
  if (!thread) throw new AssistantMessageError('CONVERSATION_NOT_FOUND');
78
- const { messageStorage, saveMessage, phiProcessor, executeWithPipeline, assistantProcessor, getAbortSignal } = dependencies;
78
+ const { messageStorage, saveMessage, phiProcessor, executeWithPipeline, assistantProcessor, getAbortSignal, recordPatientSend } = dependencies;
79
79
  if (typeof messageStorage?.saveMessage !== 'function') throw new AssistantMessageError('MESSAGE_PERSISTENCE_FAILED');
80
80
 
81
81
  let acceptedCount = 0;
@@ -115,6 +115,7 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
115
115
  }
116
116
 
117
117
  if (!reply) return;
118
+ recordPatientSend?.({ code, channel: 'assistant_instruction', sentBy: triggeredBy, text: input.messages.join('\n') });
118
119
 
119
120
  try {
120
121
  await executeWithPipeline(code, 'system', 'queue',
@@ -72,6 +72,9 @@ const RECHECK_DELAY_MS = 60 * 1000;
72
72
  const ALERT_RETRY_DELAY_MS = 2 * 60 * 1000;
73
73
  const MAX_ALERT_ATTEMPTS = 3;
74
74
  const BOOKKEEPING = { timestamps: false };
75
+ const SEND_CHANNELS = ['assistant_instruction', 'staff_message'];
76
+ const MAX_SENDS = 100;
77
+ const MAX_SEND_LENGTH = 4000;
75
78
  const PREVIOUS_REVIEW_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
76
79
  const DUPLICATE_KEY_ERROR = 11000;
77
80
  const HANDING_OFF_WAIT_MS = 3000;
@@ -1941,6 +1944,27 @@ async function recordDirectIndication({ code, triggeredBy, body, messageId = nul
1941
1944
  }
1942
1945
  }
1943
1946
 
1947
+ // A log, not a state change: it leaves updatedAt alone so it never trips another write's guard.
1948
+ async function recordPatientSend({ code, channel, sentBy, text, messageId = null, deferred = false } = {}) {
1949
+ try {
1950
+ const operator = String(sentBy ?? '').trim();
1951
+ const body = String(text ?? '').trim();
1952
+ if (!SEND_CHANNELS.includes(channel) || !operator.includes('@') || !body || !PATIENT_CODE.test(String(code ?? ''))) return false;
1953
+ const send = { channel, text: body.slice(0, MAX_SEND_LENGTH), messageId, deferred: Boolean(deferred), sentBy: operator, sentAt: new Date() };
1954
+ const { modifiedCount } = await getEscalationReview().updateOne(
1955
+ { patientCode: patientCodeOf(code), closedAt: null },
1956
+ { $push: { patientSends: { $each: [send], $slice: -MAX_SENDS } } },
1957
+ BOOKKEEPING,
1958
+ );
1959
+ return modifiedCount > 0;
1960
+ } catch (error) {
1961
+ logger.error('[EscalationReview] A message to the patient could not be recorded on the open review', {
1962
+ channel, error: redactedError(error), stack: redactedStack(error),
1963
+ });
1964
+ return false;
1965
+ }
1966
+ }
1967
+
1944
1968
  const latestUpdateGuard = (review) => {
1945
1969
  const updates = review.updates ?? [];
1946
1970
  if (!updates.length) return { 'updates.0': { $exists: false } };
@@ -2241,6 +2265,7 @@ module.exports = {
2241
2265
  reclassifyUrgency,
2242
2266
  recordEscalationAlarm,
2243
2267
  recordDirectIndication,
2268
+ recordPatientSend,
2244
2269
  recordDoctorDecision,
2245
2270
  sendScheduledDoctorAlert,
2246
2271
  recordReviewUpdate,
@@ -1,11 +1,12 @@
1
1
  const { logger } = require('../utils/logger');
2
2
  const { isWithinHourWindow, contactHoursBetween } = require('../utils/scheduleUtils');
3
+ const { isAtOrBefore } = require('../utils/dateUtils');
3
4
 
4
5
  const { Message } = require('../models/messageModel');
5
6
 
6
7
  const { isWithin24HourWindow } = require('../helpers/conversationWindowHelper');
7
8
  const { contactOf } = require('../helpers/caregiverHelper');
8
- const { cleanupRecoveryTemplate, resolveRecoveryBody, hasUnresolvedPhi } = require('../helpers/templateRecoveryHelper');
9
+ const { cleanupRecoveryTemplate, resolveRecoveryBody, hasUnresolvedPhi, closeExpiredRecovery } = require('../helpers/templateRecoveryHelper');
9
10
 
10
11
  const { getMessages } = require('./messageService');
11
12
 
@@ -39,6 +40,11 @@ async function releaseClaim(messageId) {
39
40
  async function releaseOne(message, now) {
40
41
  const logCtx = { messageId: String(message._id), to: message.numero };
41
42
 
43
+ if (isAtOrBefore(message.expiresAt, now)) {
44
+ await closeExpiredRecovery(message);
45
+ return false;
46
+ }
47
+
42
48
  const aged = contactHoursBetween(message.createdAt, now, PATIENT_CONTACT_WINDOW);
43
49
  if (aged === null || aged > MAX_CONTACT_HOURS) {
44
50
  logger.info('[HeldMessage] Held message is past its freshness bound, leaving it for staff', {
@@ -130,6 +130,7 @@ class MongoStorage {
130
130
  statusInfo: messageData.statusInfo,
131
131
  clinical_context: messageData.clinicalContext || null,
132
132
  triggeredBy: messageData.triggeredBy || null,
133
+ expiresAt: messageData.expiresAt,
133
134
  ...(messageData.contactCode && {
134
135
  contact_code: ensureWhatsAppFormat(messageData.contactCode),
135
136
  contact_role: messageData.contactRole || null,
@@ -104,6 +104,11 @@ const addWorkingMinutes = (start, minutes, { timeZone, days, startHour, endHour
104
104
  return null;
105
105
  };
106
106
 
107
+ function isAtOrBefore(when, now) {
108
+ const at = when ? new Date(when).getTime() : NaN;
109
+ return Number.isFinite(at) && at <= new Date(now).getTime();
110
+ }
111
+
107
112
  module.exports = {
108
113
  addDays,
109
114
  addWorkingMinutes,
@@ -121,5 +126,6 @@ module.exports = {
121
126
  mexicoIsoWeek,
122
127
  mexicoWeekStart,
123
128
  mexicoStamp,
124
- daysSince
129
+ daysSince,
130
+ isAtOrBefore
125
131
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.3.0-dev.18429",
3
+ "version": "6.3.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",