@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 +5 -0
- package/lib/clinical/services/clinicalAirtableService.js +1 -5
- package/lib/clinical/tools/deliverPatientMessageTool.js +1 -1
- package/lib/controllers/messageController.js +33 -8
- package/lib/core/NexusMessaging.js +29 -3
- package/lib/helpers/channelRetryHelper.js +8 -1
- package/lib/helpers/templateRecoveryHelper.js +25 -1
- package/lib/index.d.ts +1 -0
- package/lib/jobs/ScheduledMessageJob.js +10 -0
- package/lib/jobs/TemplateApprovalJob.js +12 -1
- package/lib/models/agendaMessageModel.js +1 -0
- package/lib/models/escalationReviewModel.js +10 -0
- package/lib/models/messageModel.js +2 -1
- package/lib/services/assistantMessageService.js +2 -1
- package/lib/services/escalationReviewService.js +25 -0
- package/lib/services/heldMessageService.js +7 -1
- package/lib/storage/MongoStorage.js +1 -0
- package/lib/utils/dateUtils.js +7 -1
- package/package.json +1 -1
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:
|
|
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,
|
package/lib/utils/dateUtils.js
CHANGED
|
@@ -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
|
};
|