@peopl-health/nexus 5.68.2 → 5.68.4
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/lib/clinical/helpers/dateAnchorHelper.js +8 -2
- package/lib/clinical/providers/BaseLLMProvider.js +9 -1
- package/lib/clinical/services/outreachSweepService.js +17 -5
- package/lib/clinical/services/pausedSafetyNetService.js +3 -3
- package/lib/clinical/tools/schedulePatientReminderTool.js +18 -2
- package/lib/controllers/assistantController.js +4 -0
- package/lib/core/NexusMessaging.js +14 -6
- package/lib/utils/dateUtils.js +12 -4
- package/package.json +1 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
const { getCurrentMexicoDateTime, getMexicoDayLabels } = require('../../utils/dateUtils');
|
|
1
|
+
const { getCurrentMexicoDateTime, getMexicoDayLabels, getMexicoUpcomingDayLabels } = require('../../utils/dateUtils');
|
|
2
2
|
|
|
3
3
|
const ANCHOR_SPAN_DAYS = 7;
|
|
4
|
+
const UPCOMING_SPAN_DAYS = 14;
|
|
4
5
|
|
|
5
6
|
function buildDateAnchor() {
|
|
6
7
|
const at = Date.now();
|
|
7
8
|
const labels = getMexicoDayLabels({ days: ANCHOR_SPAN_DAYS, at });
|
|
8
9
|
const week = labels.map((label, i) => (i === labels.length - 1 ? `${label} (hoy)` : label));
|
|
9
|
-
|
|
10
|
+
const upcoming = getMexicoUpcomingDayLabels({ days: UPCOMING_SPAN_DAYS, at });
|
|
11
|
+
return [
|
|
12
|
+
getCurrentMexicoDateTime(at),
|
|
13
|
+
`Los últimos siete días: ${week.join('; ')}.`,
|
|
14
|
+
`Los próximos días: ${upcoming.join('; ')}.`,
|
|
15
|
+
].join('\n');
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
module.exports = { buildDateAnchor };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const moment = require('moment-timezone');
|
|
2
|
+
|
|
1
3
|
const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
|
|
2
4
|
const { REFUSAL, isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
|
|
3
5
|
const { isToolFailure, toolRecoveryContract } = require('../helpers/toolResultHelper');
|
|
@@ -14,6 +16,7 @@ const runtimeConfig = require('../../config/runtimeConfig');
|
|
|
14
16
|
const { TAIL_PLACEMENT, CURRENT_TURN } = require('../promptPlacement');
|
|
15
17
|
const { logger } = require('../../utils/logger');
|
|
16
18
|
const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
|
|
19
|
+
const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
|
|
17
20
|
const { isJsonBlob } = require('../../utils/formatUtils');
|
|
18
21
|
const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
|
|
19
22
|
const { MAX_ADDITIONAL_TOOL_IDS } = require('../../core/runtimeToolPolicy');
|
|
@@ -33,6 +36,11 @@ const outreachMinute = (value) => {
|
|
|
33
36
|
return match ? `${match[1]}T${match[2]}` : null;
|
|
34
37
|
};
|
|
35
38
|
|
|
39
|
+
const localFireTime = (value) => {
|
|
40
|
+
const at = moment.tz(value, DEFAULT_TIMEZONE);
|
|
41
|
+
return value && at.isValid() ? `${at.format('YYYY-MM-DD HH:mm')} (hora de la paciente)` : sanitizeInlineText(value);
|
|
42
|
+
};
|
|
43
|
+
|
|
36
44
|
const NEGATIVE_GUIDANCE_MARKER = '**When NOT to call';
|
|
37
45
|
const CONTINGENCY_RULE = [
|
|
38
46
|
'[REGLA DE CONTINGENCIA (prioritaria) para este turno]',
|
|
@@ -439,7 +447,7 @@ class BaseLLMProvider {
|
|
|
439
447
|
_buildFollowUpMessage(promptVariables) {
|
|
440
448
|
const pending = Array.isArray(promptVariables?.active_follow_ups) ? promptVariables.active_follow_ups : [];
|
|
441
449
|
if (!pending.length) return [];
|
|
442
|
-
const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${
|
|
450
|
+
const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${localFireTime(item.fireAt)}: ${sanitizeInlineText(item.message, 600)}`);
|
|
443
451
|
return [{
|
|
444
452
|
role: 'developer',
|
|
445
453
|
content: [
|
|
@@ -5,6 +5,7 @@ const { Symptoms_ID } = require('../../config/airtableConfig');
|
|
|
5
5
|
const { getMessages } = require('../../services/messageService');
|
|
6
6
|
const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
|
|
7
7
|
const { logger } = require('../../utils/logger');
|
|
8
|
+
const { botIsPaused } = require('../../helpers/pausedThreadHelper');
|
|
8
9
|
const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
|
|
9
10
|
const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
|
|
10
11
|
|
|
@@ -48,6 +49,16 @@ const AUDIT = {
|
|
|
48
49
|
turnId: 'seguimiento_turn_id',
|
|
49
50
|
};
|
|
50
51
|
|
|
52
|
+
const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
|
|
53
|
+
const CONTACT_SENT = {
|
|
54
|
+
from_me: true,
|
|
55
|
+
origin: { $nin: ['instruction', 'system'] },
|
|
56
|
+
$or: [
|
|
57
|
+
{ 'statusInfo.status': { $nin: UNDELIVERED_STATUSES } },
|
|
58
|
+
{ 'statusInfo.recoveryTemplateSid': { $ne: null }, 'statusInfo.recoverySentAt': null },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
51
62
|
const RECENT_OUTBOUND_HOURS = 24;
|
|
52
63
|
const COLLIDING_REMINDER_MINUTES = 24 * 60;
|
|
53
64
|
const INACTIVITY_DAYS = 7;
|
|
@@ -226,6 +237,7 @@ async function rejectionReason(candidate, {
|
|
|
226
237
|
} = {}) {
|
|
227
238
|
const opsReason = opsOptedOut(candidate, { now });
|
|
228
239
|
if (opsReason) return opsReason;
|
|
240
|
+
if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
|
|
229
241
|
if (!includePreviouslySwept) {
|
|
230
242
|
const sweptReason = priorSweepReason(candidate);
|
|
231
243
|
if (sweptReason) return sweptReason;
|
|
@@ -236,7 +248,7 @@ async function rejectionReason(candidate, {
|
|
|
236
248
|
const recentOutbound = await getMessages(
|
|
237
249
|
{
|
|
238
250
|
numero: candidate.code,
|
|
239
|
-
|
|
251
|
+
...CONTACT_SENT,
|
|
240
252
|
createdAt: { $gt: since },
|
|
241
253
|
...(mode === 'monday_triage_recovery' && candidate.triageContentSid
|
|
242
254
|
? { content_sid: { $ne: candidate.triageContentSid } }
|
|
@@ -271,7 +283,7 @@ async function rejectionReason(candidate, {
|
|
|
271
283
|
const recentOutreach = await getMessages(
|
|
272
284
|
{
|
|
273
285
|
numero: candidate.code,
|
|
274
|
-
|
|
286
|
+
...CONTACT_SENT,
|
|
275
287
|
triggeredBy: { $regex: '^(?:seguimiento:|followup_)' },
|
|
276
288
|
createdAt: { $gte: inactiveSince },
|
|
277
289
|
},
|
|
@@ -283,7 +295,7 @@ async function rejectionReason(candidate, {
|
|
|
283
295
|
const lastTwoOutreaches = await getMessages(
|
|
284
296
|
{
|
|
285
297
|
numero: candidate.code,
|
|
286
|
-
|
|
298
|
+
...CONTACT_SENT,
|
|
287
299
|
triggeredBy: { $regex: '^(?:seguimiento:|followup_)' },
|
|
288
300
|
createdAt: { $gte: unansweredSince },
|
|
289
301
|
},
|
|
@@ -320,7 +332,7 @@ async function routineOutreachCollisionReason(code, { now = new Date() } = {}) {
|
|
|
320
332
|
{ sort: { createdAt: -1 }, limit: 1 },
|
|
321
333
|
),
|
|
322
334
|
getMessages(
|
|
323
|
-
{ numero: code,
|
|
335
|
+
{ numero: code, ...CONTACT_SENT, createdAt: { $gte: today } },
|
|
324
336
|
{ sort: { createdAt: -1 }, limit: 1 },
|
|
325
337
|
),
|
|
326
338
|
]);
|
|
@@ -331,7 +343,7 @@ async function routineOutreachCollisionReason(code, { now = new Date() } = {}) {
|
|
|
331
343
|
|
|
332
344
|
async function enrichBrief(candidate) {
|
|
333
345
|
const [lastInbound] = await getMessages({ numero: candidate.code, from_me: false }, { sort: { createdAt: -1 }, limit: 1 });
|
|
334
|
-
const [lastOutbound] = await getMessages({ numero: candidate.code,
|
|
346
|
+
const [lastOutbound] = await getMessages({ numero: candidate.code, ...CONTACT_SENT }, { sort: { createdAt: -1 }, limit: 1 });
|
|
335
347
|
const fmt = (doc) => (doc?.createdAt ? moment.tz(doc.createdAt, DEFAULT_TIMEZONE).format('YYYY-MM-DD HH:mm') : null);
|
|
336
348
|
return {
|
|
337
349
|
...candidate.brief,
|
|
@@ -219,9 +219,9 @@ async function applyChoice(code, choice) {
|
|
|
219
219
|
const queued = await fileUrgentRequest(code, choice);
|
|
220
220
|
if (!queued) return { queued, pledge: null };
|
|
221
221
|
|
|
222
|
-
await cancelPendingFollowUps({ code, reason: 'safety_net_request' })
|
|
223
|
-
logger.error('[PausedSafetyNet] Pending follow-ups were not stood down', { code
|
|
224
|
-
|
|
222
|
+
if (!(await cancelPendingFollowUps({ code, reason: 'safety_net_request' }))) {
|
|
223
|
+
logger.error('[PausedSafetyNet] Pending follow-ups were not stood down', { code });
|
|
224
|
+
}
|
|
225
225
|
|
|
226
226
|
try {
|
|
227
227
|
return { queued, pledge: await openPledge({ code }) };
|
|
@@ -6,11 +6,12 @@ const { recordReminder, readPendingFollowUpsForPatient, cancelFollowUp } = requi
|
|
|
6
6
|
const { isWithinTriageProtectionWindow } = require('../services/outreachSweepService');
|
|
7
7
|
const { UTILITY_BASES, sanitizeVariable } = require('../services/outreachCarrierService');
|
|
8
8
|
const { logger } = require('../../utils/logger');
|
|
9
|
-
const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
|
|
9
|
+
const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
|
|
10
|
+
const { botIsPaused } = require('../../helpers/pausedThreadHelper');
|
|
10
11
|
|
|
11
12
|
const definition = {
|
|
12
13
|
name: 'schedulePatientReminder',
|
|
13
|
-
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis. An open contingency net does not prevent one: the net awaits its datum, the seguimiento asks about what was indicated (e.g. whether a visit or test was booked).\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
|
|
14
|
+
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis. An open contingency net does not prevent one: the net awaits its datum, the seguimiento asks about what was indicated (e.g. whether a visit or test was booked).\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead and only between 11:00 and 21:00 patient-local time, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
|
|
14
15
|
strict: true,
|
|
15
16
|
parameters: {
|
|
16
17
|
type: 'object',
|
|
@@ -52,6 +53,9 @@ const REMINDER_TYPES = definition.parameters.properties.reminder_type.enum;
|
|
|
52
53
|
const ACTION_TYPES = definition.parameters.properties.reminder_action_type.enum;
|
|
53
54
|
|
|
54
55
|
const MAX_FOLLOW_UP_LEAD_HOURS = 48;
|
|
56
|
+
const ROUTINE_OUTREACH_START_HOUR = 11;
|
|
57
|
+
const ROUTINE_OUTREACH_END_HOUR = 21;
|
|
58
|
+
const PAUSED_ERROR = 'thread_paused — do not schedule a seguimiento in this turn.';
|
|
55
59
|
|
|
56
60
|
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/;
|
|
57
61
|
const FIRE_AT_FORMATS = ['YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm'];
|
|
@@ -128,7 +132,13 @@ async function handler(args = {}, context = {}) {
|
|
|
128
132
|
if (isRoutineOutreach && isWithinTriageProtectionWindow(fireMoment.toDate())) {
|
|
129
133
|
return JSON.stringify({ success: false, error: 'routine outreach cannot be scheduled during the 07:30-11:00 triage protection window; choose 11:00 or later.', data: {} });
|
|
130
134
|
}
|
|
135
|
+
if (isRoutineOutreach && !isWithinHourWindow(fireMoment.toDate(), { timeZone, startHour: ROUTINE_OUTREACH_START_HOUR, endHour: ROUTINE_OUTREACH_END_HOUR })) {
|
|
136
|
+
return JSON.stringify({ success: false, error: `routine outreach must go out between ${ROUTINE_OUTREACH_START_HOUR}:00 and ${ROUTINE_OUTREACH_END_HOUR}:00 patient time; choose a time in that range.`, data: {} });
|
|
137
|
+
}
|
|
131
138
|
if (isRoutineOutreach && actionType === 'create' && runtime.patientCode) {
|
|
139
|
+
if (await botIsPaused(runtime.patientCode, { label: '[schedulePatientReminder]' })) {
|
|
140
|
+
return JSON.stringify({ success: false, error: PAUSED_ERROR, data: {} });
|
|
141
|
+
}
|
|
132
142
|
const pending = await readPendingFollowUpsForPatient({ code: runtime.patientCode });
|
|
133
143
|
if (pending.length) {
|
|
134
144
|
return JSON.stringify({ success: false, error: 'active_follow_up_exists — update the existing plan instead of crossing it.', data: { reminder_id: pending[0].reminderId } });
|
|
@@ -182,6 +192,12 @@ async function handler(args = {}, context = {}) {
|
|
|
182
192
|
if (!persisted) {
|
|
183
193
|
return JSON.stringify({ success: false, error: 'reminder persistence failed; retry scheduling', data: {} });
|
|
184
194
|
}
|
|
195
|
+
if (isRoutineOutreach && actionType === 'create' && await botIsPaused(runtime.patientCode, { label: '[schedulePatientReminder]' })) {
|
|
196
|
+
if (!await cancelFollowUp({ code: runtime.patientCode, reminderId, trace, reason: 'thread_paused' })) {
|
|
197
|
+
logger.error('[schedulePatientReminder] seguimiento created on a paused thread was not stood down', { reminderId });
|
|
198
|
+
}
|
|
199
|
+
return JSON.stringify({ success: false, error: PAUSED_ERROR, data: {} });
|
|
200
|
+
}
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
trace.setSignals({ reminderIntents: [...existing, intent] });
|
|
@@ -9,6 +9,7 @@ const { getThreadInfo, setThreadDoNotAlert, switchThreadStoppedStatus } = requir
|
|
|
9
9
|
const { getRecordByFilter } = require('../services/airtableService');
|
|
10
10
|
const { sendMessage, processInstruction, processSystemMessage } = require('../core/NexusMessaging');
|
|
11
11
|
const { createAssistant, switchAssistant } = require('../clinical');
|
|
12
|
+
const { cancelPendingFollowUps } = require('../clinical/services/clinicalAirtableService');
|
|
12
13
|
|
|
13
14
|
const asBoolean = (value) => (typeof value === 'string' ? value === 'true' : !!value);
|
|
14
15
|
|
|
@@ -19,6 +20,9 @@ const stopAssistantController = async (req, res) => {
|
|
|
19
20
|
const stopped = asBoolean(stop);
|
|
20
21
|
try {
|
|
21
22
|
await switchThreadStoppedStatus(code, stopped, { byOperator: true });
|
|
23
|
+
if (stopped && !(await cancelPendingFollowUps({ code, reason: 'thread_paused' }))) {
|
|
24
|
+
logger.error('[AssistantController] Pending follow-ups were not stood down on pause', { code });
|
|
25
|
+
}
|
|
22
26
|
return res.status(200).json({ success: true, message: stopped ? 'Assistant stopped' : 'Assistant resumed' });
|
|
23
27
|
} catch (error) {
|
|
24
28
|
logger.error('[AssistantController] Failed to stop assistant', { error: error.message, code });
|
|
@@ -20,6 +20,7 @@ const { isWithin24HourWindow } = require('../helpers/conversationWindowHelper');
|
|
|
20
20
|
const { maskCaregiverLabel, remapCaregiverSender, replyContactFor } = require('../helpers/caregiverHelper');
|
|
21
21
|
const { triggerTemplateRecovery } = require('../helpers/templateRecoveryHelper');
|
|
22
22
|
const { prepareMessageMedia } = require('../helpers/mediaHelper');
|
|
23
|
+
const { botIsPaused } = require('../helpers/pausedThreadHelper');
|
|
23
24
|
const { acquireSendClaim, completeSendClaim, releaseSendClaim } = require('../helpers/sendClaimHelper');
|
|
24
25
|
|
|
25
26
|
const { createMessagingProvider } = require('../adapters/registry');
|
|
@@ -490,6 +491,10 @@ class NexusMessaging {
|
|
|
490
491
|
logger.info('[NexusMessaging] proactive send cancelled — patient became active', { code: messageData.code });
|
|
491
492
|
return { success: false, messageId: null, status: 'patient_active', delivered: false, deferred: false };
|
|
492
493
|
}
|
|
494
|
+
if (await botIsPaused(messageData.code, { label: '[NexusMessaging]' })) {
|
|
495
|
+
logger.info('[NexusMessaging] proactive send cancelled — bot paused by the team', { code: messageData.code });
|
|
496
|
+
return { success: false, messageId: null, status: 'bot_paused', delivered: false, deferred: false };
|
|
497
|
+
}
|
|
493
498
|
}
|
|
494
499
|
if (!this.provider) throw new Error('No provider initialized');
|
|
495
500
|
|
|
@@ -1027,6 +1032,9 @@ class NexusMessaging {
|
|
|
1027
1032
|
if (delivery?.status === 'patient_active') {
|
|
1028
1033
|
return { decision: 'skipped', rationale: 'la paciente escribió durante la decisión', holdReason: 'patient_active', nextCheckAt: null, draft: null, accepted: false, delivered: false, deferred: false, deliveryStatus: 'patient_active', turnId: runResult.turnId || null, traceId: runResult.traceId || null };
|
|
1029
1034
|
}
|
|
1035
|
+
if (delivery?.status === 'bot_paused') {
|
|
1036
|
+
return { decision: 'skipped', rationale: 'el equipo pausó el bot durante la decisión', holdReason: 'bot_paused', nextCheckAt: null, draft: null, accepted: false, delivered: false, deferred: false, deliveryStatus: 'bot_paused', turnId: runResult.turnId || null, traceId: runResult.traceId || null };
|
|
1037
|
+
}
|
|
1030
1038
|
const accepted = Boolean(runResult.output) && delivery?.success === true && delivery?.deferred !== true;
|
|
1031
1039
|
const delivered = ['delivered', 'read'].includes(String(delivery?.status || '').toLowerCase());
|
|
1032
1040
|
if (!dryRun && (decision.decision === 'write') !== accepted) {
|
|
@@ -1050,18 +1058,18 @@ class NexusMessaging {
|
|
|
1050
1058
|
};
|
|
1051
1059
|
}
|
|
1052
1060
|
|
|
1053
|
-
/*
|
|
1054
|
-
* Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
|
|
1055
|
-
* The clock only wakes this method; it does not choose copy or timing. Running inside the same
|
|
1056
|
-
* patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
|
|
1057
|
-
* the follow-up.
|
|
1058
|
-
*/
|
|
1059
1061
|
async _retireSkippedFollowUp(code, reminderId, holdReason) {
|
|
1060
1062
|
const retired = await cancelFollowUp({ code, reminderId, reason: `skipped_${holdReason}` });
|
|
1061
1063
|
if (!retired) logger.warn('[processScheduledFollowUp] skipped follow-up was not stood down', { reminderId, holdReason });
|
|
1062
1064
|
return { decision: 'skipped', holdReason, accepted: false, delivered: false, retired };
|
|
1063
1065
|
}
|
|
1064
1066
|
|
|
1067
|
+
/*
|
|
1068
|
+
* Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
|
|
1069
|
+
* The clock only wakes this method; it does not choose copy or timing. Running inside the same
|
|
1070
|
+
* patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
|
|
1071
|
+
* the follow-up.
|
|
1072
|
+
*/
|
|
1065
1073
|
async processScheduledFollowUp(code, { reminderId, firstName, now = new Date() } = {}) {
|
|
1066
1074
|
if (!code || !reminderId) throw new Error('code and reminderId are required');
|
|
1067
1075
|
if (typeof firstName !== 'string' || !firstName.trim()) throw new Error('firstName is required');
|
package/lib/utils/dateUtils.js
CHANGED
|
@@ -6,13 +6,20 @@ const nowInMexico = (at) => moment(at).tz(MEXICO_TZ).locale('es');
|
|
|
6
6
|
|
|
7
7
|
const getCurrentMexicoDateTime = (at) => nowInMexico(at).format(MEXICO_STAMP);
|
|
8
8
|
|
|
9
|
+
const dayLabel = (day, now) => (day.year() === now.year() ? day.format('dddd D [de] MMMM') : day.format('dddd D [de] MMMM [de] YYYY'));
|
|
10
|
+
|
|
9
11
|
const getMexicoDayLabels = ({ days, at }) => {
|
|
10
12
|
const now = nowInMexico(at);
|
|
11
13
|
const labels = [];
|
|
12
|
-
for (let back = days - 1; back >= 0; back -= 1)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
for (let back = days - 1; back >= 0; back -= 1) labels.push(dayLabel(now.clone().subtract(back, 'days'), now));
|
|
15
|
+
return labels;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// The days after today, nearest first; today itself is not included.
|
|
19
|
+
const getMexicoUpcomingDayLabels = ({ days, at }) => {
|
|
20
|
+
const now = nowInMexico(at);
|
|
21
|
+
const labels = [];
|
|
22
|
+
for (let ahead = 1; ahead <= days; ahead += 1) labels.push(dayLabel(now.clone().add(ahead, 'days'), now));
|
|
16
23
|
return labels;
|
|
17
24
|
};
|
|
18
25
|
|
|
@@ -83,6 +90,7 @@ module.exports = {
|
|
|
83
90
|
addWorkingMinutes,
|
|
84
91
|
getCurrentMexicoDateTime,
|
|
85
92
|
getMexicoDayLabels,
|
|
93
|
+
getMexicoUpcomingDayLabels,
|
|
86
94
|
parseDate,
|
|
87
95
|
toIsoInstant,
|
|
88
96
|
ageInYears,
|