@peopl-health/nexus 6.3.0-dev.18381 → 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/services/escalationDraftService.js +36 -3
- package/lib/clinical/services/treatmentProposalService.js +1 -0
- 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 +40 -0
- package/lib/models/messageModel.js +2 -1
- package/lib/services/assistantMessageService.js +2 -1
- package/lib/services/characterizationOutcomeService.js +1 -2
- package/lib/services/escalationReviewService.js +27 -1
- package/lib/services/gravePendingService.js +0 -1
- package/lib/services/heldMessageService.js +7 -1
- package/lib/services/reviewDeadlineService.js +1 -1
- package/lib/services/untakenAlertService.js +1 -2
- 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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { getEscalationDraft } = require('../config/subAgentsConfig');
|
|
2
2
|
|
|
3
|
-
const { FORMULARY_VERSION, TREATMENT_FORMULARY } = require('../protocols/treatmentFormulary');
|
|
3
|
+
const { FORMULARY_VERSION, TREATMENT_FORMULARY, findDrug, formularyFor } = require('../protocols/treatmentFormulary');
|
|
4
4
|
|
|
5
5
|
const { createLLMProvider } = require('../providers/createLLMProvider');
|
|
6
6
|
|
|
@@ -9,12 +9,12 @@ const { readStructuredText } = require('../helpers/structuredTextHelper');
|
|
|
9
9
|
|
|
10
10
|
const { getEscalationContext } = require('../../services/escalationContextService');
|
|
11
11
|
|
|
12
|
-
const { normalizedKeyOf } = require('../../utils/coercionUtils');
|
|
12
|
+
const { inlineOf, normalizedKeyOf } = require('../../utils/coercionUtils');
|
|
13
13
|
const { logger } = require('../../utils/logger');
|
|
14
14
|
const { arrayArg, safeParse } = require('../../utils/jsonUtils');
|
|
15
15
|
const { withTiming } = require('../../utils/tracingDecorator');
|
|
16
16
|
|
|
17
|
-
const { crossSymptomIssues, drugsNamedIn, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
|
|
17
|
+
const { JUSTIFICATION_KINDS, crossSymptomIssues, drugsNamedIn, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
|
|
18
18
|
|
|
19
19
|
const CONVERSATION_TURNS = 40;
|
|
20
20
|
const TRIAGE_LIMIT = 3;
|
|
@@ -46,6 +46,38 @@ function settleProposal(parsed) {
|
|
|
46
46
|
|
|
47
47
|
const textList = (value) => (Array.isArray(value) ? value.map(trimmed).filter(Boolean) : []);
|
|
48
48
|
|
|
49
|
+
function symptomRecordOf({ block, proposal, conduct }, confirmed) {
|
|
50
|
+
const { present, absent, unassessed } = flagsByStatus(block);
|
|
51
|
+
const entry = proposal && formularyFor(proposal.symptom);
|
|
52
|
+
const itemOf = (item) => {
|
|
53
|
+
const drug = findDrug(entry, item);
|
|
54
|
+
return {
|
|
55
|
+
drug: drug.drug,
|
|
56
|
+
brand: drug.brand ?? null,
|
|
57
|
+
strength: drug.strength ?? null,
|
|
58
|
+
instructions: drug.instructions ?? null,
|
|
59
|
+
position: drug.position,
|
|
60
|
+
justification: trimmed(item.justification?.text) || null,
|
|
61
|
+
justificationKind: JUSTIFICATION_KINDS.includes(item.justification?.kind) ? item.justification.kind : null,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
symptom: inlineOf(block.symptom) || null,
|
|
66
|
+
grade: [1, 2, 3, 4, 5].includes(block.grade) ? block.grade : null,
|
|
67
|
+
presentRedFlags: present,
|
|
68
|
+
absentRedFlags: absent,
|
|
69
|
+
unassessedRedFlags: unassessed,
|
|
70
|
+
proposal: proposal ? {
|
|
71
|
+
formularyKey: entry.key,
|
|
72
|
+
items: proposal.items.map(itemOf),
|
|
73
|
+
conditions: textList(proposal.conditions),
|
|
74
|
+
ruledOut: textList(proposal.ruledOut),
|
|
75
|
+
confirmed,
|
|
76
|
+
} : null,
|
|
77
|
+
conduct: conduct ?? null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
function suggestCallOf(value) {
|
|
50
82
|
if (typeof value?.value !== 'boolean') return null;
|
|
51
83
|
return { value: value.value, reason: trimmed(value.reason) || null };
|
|
@@ -231,6 +263,7 @@ async function generateEscalationDraft({
|
|
|
231
263
|
blockingFlag: sufficientForDoctor === false ? trimmed(parsed?.blockingFlag) || null : null,
|
|
232
264
|
suggestCall: suggestCallOf(parsed?.suggestCall),
|
|
233
265
|
sectionsUnavailable: context.degradedSections ?? [],
|
|
266
|
+
symptoms: settled.map((entry) => symptomRecordOf(entry, isConfirmed(entry))),
|
|
234
267
|
};
|
|
235
268
|
}
|
|
236
269
|
|
|
@@ -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
|
|
|
@@ -133,6 +133,35 @@ const suggestCallSchema = new mongoose.Schema({
|
|
|
133
133
|
reason: { type: String, default: null },
|
|
134
134
|
}, { _id: false });
|
|
135
135
|
|
|
136
|
+
const proposedItemSchema = new mongoose.Schema({
|
|
137
|
+
drug: { type: String, required: true },
|
|
138
|
+
brand: { type: String, default: null },
|
|
139
|
+
strength: { type: String, default: null },
|
|
140
|
+
instructions: { type: String, default: null },
|
|
141
|
+
position: { type: String, default: null },
|
|
142
|
+
justification: { type: String, default: null },
|
|
143
|
+
justificationKind: { type: String, default: null },
|
|
144
|
+
}, { _id: false });
|
|
145
|
+
|
|
146
|
+
const draftSymptomSchema = new mongoose.Schema({
|
|
147
|
+
symptom: { type: String, default: null },
|
|
148
|
+
grade: { type: Number, default: null },
|
|
149
|
+
presentRedFlags: { type: [String], default: undefined },
|
|
150
|
+
absentRedFlags: { type: [String], default: undefined },
|
|
151
|
+
unassessedRedFlags: { type: [String], default: undefined },
|
|
152
|
+
proposal: {
|
|
153
|
+
type: new mongoose.Schema({
|
|
154
|
+
formularyKey: { type: String, required: true },
|
|
155
|
+
items: { type: [proposedItemSchema], default: undefined },
|
|
156
|
+
conditions: { type: [String], default: undefined },
|
|
157
|
+
ruledOut: { type: [String], default: undefined },
|
|
158
|
+
confirmed: { type: Boolean, default: false },
|
|
159
|
+
}, { _id: false }),
|
|
160
|
+
default: null,
|
|
161
|
+
},
|
|
162
|
+
conduct: { type: String, default: null },
|
|
163
|
+
}, { _id: false });
|
|
164
|
+
|
|
136
165
|
const draftSchema = new mongoose.Schema({
|
|
137
166
|
doctorText: { type: String, required: true },
|
|
138
167
|
patientMessage: { type: String, required: true },
|
|
@@ -149,6 +178,7 @@ const draftSchema = new mongoose.Schema({
|
|
|
149
178
|
missingCharacterization: { type: [String], default: undefined },
|
|
150
179
|
suggestCall: { type: suggestCallSchema, default: undefined },
|
|
151
180
|
sectionsUnavailable: { type: [String], default: undefined },
|
|
181
|
+
symptoms: { type: [draftSymptomSchema], default: undefined },
|
|
152
182
|
snapshotAt: { type: Date, default: undefined },
|
|
153
183
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
154
184
|
|
|
@@ -187,6 +217,15 @@ const patientDeliverySchema = new mongoose.Schema({
|
|
|
187
217
|
sentBy: { type: String, required: true },
|
|
188
218
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
189
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
|
+
|
|
190
229
|
const pendingCaseMirrorSchema = new mongoose.Schema({
|
|
191
230
|
recordId: { type: String, required: true },
|
|
192
231
|
changes: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
@@ -269,6 +308,7 @@ const escalationReviewSchema = new mongoose.Schema({
|
|
|
269
308
|
doctorHandoffs: { type: [doctorHandoffSchema], default: [] },
|
|
270
309
|
decisions: { type: [decisionSchema], default: [] },
|
|
271
310
|
patientDelivery: { type: patientDeliverySchema, default: null },
|
|
311
|
+
patientSends: { type: [patientSendSchema], default: undefined },
|
|
272
312
|
pendingCaseMirrors: { type: [pendingCaseMirrorSchema], default: [] },
|
|
273
313
|
interruptionResolution: { type: interruptionResolutionSchema, default: null },
|
|
274
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',
|
|
@@ -57,7 +57,6 @@ const graveDueAt = (review, clock, flag) => {
|
|
|
57
57
|
function outcomeOf(review, clock, now) {
|
|
58
58
|
const wait = pendingWaitMinutes(clock?.level);
|
|
59
59
|
if (!wait || !OUTCOME_LEVELS.includes(clock.level) || !['draft_ready', 'awaiting_doctor'].includes(review.status)) return null;
|
|
60
|
-
// Handoffs count since the last decision, so a higher alarm on a case already with the doctor goes as an update.
|
|
61
60
|
const decidedAt = Math.max(0, ...(review.decisions ?? []).map((decision) => timeOf(decision.createdAt)));
|
|
62
61
|
const handoffs = (review.doctorHandoffs ?? []).filter((handoff) => timeOf(handoff.createdAt) > decidedAt);
|
|
63
62
|
const last = handoffs.at(-1);
|
|
@@ -74,7 +73,7 @@ function outcomeOf(review, clock, now) {
|
|
|
74
73
|
const present = latest.urgentPresentRedFlags ?? [];
|
|
75
74
|
// An update reaches the doctor's phone only with a grave flag the draft they have did not show; the panel always shows the latest draft.
|
|
76
75
|
const handedPresent = new Set((update ? review.drafts[last.draftNumber - 1]?.urgentPresentRedFlags ?? [] : []).map(flagKey));
|
|
77
|
-
//
|
|
76
|
+
// Airtable's alert reaches the team, not the doctor's phone.
|
|
78
77
|
const raisedSince = (level) => (review.alarms ?? []).some((alarm) => alarm.urgencyLevel === level && timeOf(alarm.raisedAt) > timeOf(last.createdAt));
|
|
79
78
|
const rangSince = update && (review.doctorAlerts ?? []).some((sent) => sent.kind === 'initial' && !sent.error
|
|
80
79
|
&& sent.transport && sent.transport !== 'airtable' && timeOf(sent.sentAt) > timeOf(last.createdAt) && raisedSince(sent.urgencyLevel));
|
|
@@ -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;
|
|
@@ -1030,7 +1033,7 @@ async function caseTakenReason(review, from, level) {
|
|
|
1030
1033
|
const changes = review.urgencyReclassifications ?? [];
|
|
1031
1034
|
const levelsAt = (at) => levelsOf(currentAlarms(review).filter((alarm) => new Date(alarm.raisedAt) <= new Date(at)), changes.filter((change) => new Date(change.at) < new Date(at)), appliedLevelOf);
|
|
1032
1035
|
if (level !== 'stat' && levelsAt(new Date(8.64e15)).some((entry) => entry.level === 'stat')) return 'stat_alarm';
|
|
1033
|
-
//
|
|
1036
|
+
// A lowering means someone looked at the case; a raise restarts the clock instead.
|
|
1034
1037
|
const lowered = (change) => (URGENCY_RANK[change.toLevel] || 0) < Math.max(0, ...levelsAt(change.at).map((entry) => URGENCY_RANK[entry.level] || 0));
|
|
1035
1038
|
if (changes.some((change) => atOrAfter(change.at) && lowered(change))) return 'reclassified';
|
|
1036
1039
|
if ((review.callLogs ?? []).some((call) => call.outcome === 'contacted' && atOrAfter(call.calledAt))) return 'call_contacted';
|
|
@@ -1489,6 +1492,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
|
|
|
1489
1492
|
missingCharacterization: draft.missingCharacterization,
|
|
1490
1493
|
...(draft.suggestCall && { suggestCall: draft.suggestCall }),
|
|
1491
1494
|
...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
|
|
1495
|
+
...(draft.symptoms?.length && { symptoms: draft.symptoms }),
|
|
1492
1496
|
};
|
|
1493
1497
|
const EscalationReview = getEscalationReview();
|
|
1494
1498
|
const saved = await EscalationReview.findOneAndUpdate(
|
|
@@ -1940,6 +1944,27 @@ async function recordDirectIndication({ code, triggeredBy, body, messageId = nul
|
|
|
1940
1944
|
}
|
|
1941
1945
|
}
|
|
1942
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
|
+
|
|
1943
1968
|
const latestUpdateGuard = (review) => {
|
|
1944
1969
|
const updates = review.updates ?? [];
|
|
1945
1970
|
if (!updates.length) return { 'updates.0': { $exists: false } };
|
|
@@ -2240,6 +2265,7 @@ module.exports = {
|
|
|
2240
2265
|
reclassifyUrgency,
|
|
2241
2266
|
recordEscalationAlarm,
|
|
2242
2267
|
recordDirectIndication,
|
|
2268
|
+
recordPatientSend,
|
|
2243
2269
|
recordDoctorDecision,
|
|
2244
2270
|
sendScheduledDoctorAlert,
|
|
2245
2271
|
recordReviewUpdate,
|
|
@@ -20,7 +20,6 @@ function pendingFlagsOf(review, since) {
|
|
|
20
20
|
return { latest: drafts.at(-1) ?? null, flags: [...firstSeen].map(([flag, firstSeenAt]) => ({ flag, firstSeenAt, unobtainable: unobtainable.includes(flag) })) };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// The bot keeps documenting a case it handed to the doctor itself while it still redrafts it, so its grave questions still wait for the patient there.
|
|
24
23
|
const stillDocumented = (review) => DOCUMENTING_STATUSES.includes(review.status) || updatesDoctor(review);
|
|
25
24
|
|
|
26
25
|
// The latest draft speaks for the case only while no newer patient turn or redraft is waiting to replace it.
|
|
@@ -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', {
|
|
@@ -45,7 +45,7 @@ function responseDeadlinePolicy() {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
48
|
+
// A raise restarts the alarm's clock; a lowering keeps it.
|
|
49
49
|
function levelsOf(alarms, reclassifications, startLevel = (alarm) => alarm.urgencyLevel) {
|
|
50
50
|
const changes = [...(reclassifications ?? [])].sort((a, b) => timeOf(a.at) - timeOf(b.at));
|
|
51
51
|
return (alarms ?? []).map((alarm) => changes
|
|
@@ -34,8 +34,7 @@ const duration = (minutes) => {
|
|
|
34
34
|
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
// The episode keeps the start the alarms' own levels give it, so a reclassification never re-keys its drafts or grave alerts.
|
|
37
|
+
// Only the stages restart on a raise; the episode start ignores reclassifications so drafts and grave alerts keep their key.
|
|
39
38
|
function episodeStartOf(alarms) {
|
|
40
39
|
const top = alarms.reduce((level, alarm) => ((URGENCY_RANK[appliedLevelOf(alarm)] || 0) > (URGENCY_RANK[level] || 0) ? appliedLevelOf(alarm) : level), null);
|
|
41
40
|
return new Date(Math.min(...alarms.filter((alarm) => appliedLevelOf(alarm) === top).map((alarm) => new Date(alarm.raisedAt).getTime())));
|
|
@@ -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
|
};
|