@peopl-health/nexus 5.69.0 → 5.70.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.
|
@@ -6,8 +6,10 @@ const { getMessages } = require('../../services/messageService');
|
|
|
6
6
|
const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
|
|
7
7
|
const { logger } = require('../../utils/logger');
|
|
8
8
|
const { botIsPaused } = require('../../helpers/pausedThreadHelper');
|
|
9
|
+
const { Thread } = require('../../models/threadModel');
|
|
9
10
|
const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
|
|
10
11
|
const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
|
|
12
|
+
const { WriteClaimStore } = require('./writeClaimStore');
|
|
11
13
|
|
|
12
14
|
const TABLE = 'weekly_questionnaires';
|
|
13
15
|
const VIEW = 'seguimiento_72hrs_principal';
|
|
@@ -49,6 +51,11 @@ const AUDIT = {
|
|
|
49
51
|
turnId: 'seguimiento_turn_id',
|
|
50
52
|
};
|
|
51
53
|
|
|
54
|
+
const WEEKLY_SOURCE = 'weekly';
|
|
55
|
+
const SCOPE_SOURCE = 'patient_scope';
|
|
56
|
+
|
|
57
|
+
const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
|
|
58
|
+
|
|
52
59
|
const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
|
|
53
60
|
const CONTACT_SENT = {
|
|
54
61
|
from_me: true,
|
|
@@ -203,6 +210,56 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
|
|
|
203
210
|
return { candidates, targetDays, weekday, cadence, mode };
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
function firstWord(value) {
|
|
214
|
+
return String(value || '').trim().split(/\s+/)[0];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildScopeCandidates({ threads, excludedCodes }) {
|
|
218
|
+
const threadsByCode = new Map();
|
|
219
|
+
for (const thread of threads) threadsByCode.set(thread.code, [...(threadsByCode.get(thread.code) || []), thread]);
|
|
220
|
+
|
|
221
|
+
const candidates = [];
|
|
222
|
+
for (const [code, docs] of threadsByCode) {
|
|
223
|
+
const [thread] = docs.sort((a, b) => String(a._id).localeCompare(String(b._id)));
|
|
224
|
+
const name = firstWord(thread.nombre);
|
|
225
|
+
const names = new Set(docs.map((doc) => firstWord(doc.nombre).toLowerCase()));
|
|
226
|
+
if (excludedCodes.has(code) || !name || names.size !== 1 || !thread.patient_id) continue;
|
|
227
|
+
candidates.push({
|
|
228
|
+
source: SCOPE_SOURCE,
|
|
229
|
+
patientId: thread.patient_id,
|
|
230
|
+
recordId: String(thread._id),
|
|
231
|
+
code,
|
|
232
|
+
name,
|
|
233
|
+
reason: 'no_triage_history',
|
|
234
|
+
rows: [],
|
|
235
|
+
brief: {
|
|
236
|
+
reason: 'no hay un triaje contestado que sirva como ancla clínica',
|
|
237
|
+
lastTriageAt: null,
|
|
238
|
+
maxGrade: null,
|
|
239
|
+
positiveSymptoms: [],
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return candidates;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function loadScopeCandidates({ now, viewRows, patientScope }) {
|
|
247
|
+
const weekStart = startOfLocalWeek(now).toISOString();
|
|
248
|
+
const [threads, weekRows] = await Promise.all([
|
|
249
|
+
Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
|
|
250
|
+
airtableService.getRecordByFilter(
|
|
251
|
+
Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
|
|
252
|
+
),
|
|
253
|
+
]);
|
|
254
|
+
const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
|
|
255
|
+
.map((row) => String(first(row[F.whatsapp]) || '').trim()));
|
|
256
|
+
return buildScopeCandidates({ threads: threads || [], excludedCodes });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sweepClaimKey(candidate, now) {
|
|
260
|
+
return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
206
263
|
function opsOptedOut(candidate, { now }) {
|
|
207
264
|
const weekStart = startOfLocalWeek(now);
|
|
208
265
|
for (const row of candidate.rows) {
|
|
@@ -238,9 +295,12 @@ async function rejectionReason(candidate, {
|
|
|
238
295
|
} = {}) {
|
|
239
296
|
const opsReason = opsOptedOut(candidate, { now });
|
|
240
297
|
if (opsReason) return opsReason;
|
|
298
|
+
if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'outreach_opt_out';
|
|
241
299
|
if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
|
|
242
300
|
if (!includePreviouslySwept) {
|
|
243
|
-
const sweptReason =
|
|
301
|
+
const sweptReason = candidate.source === SCOPE_SOURCE
|
|
302
|
+
? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
|
|
303
|
+
: priorSweepReason(candidate);
|
|
244
304
|
if (sweptReason) return sweptReason;
|
|
245
305
|
|
|
246
306
|
const since = mode === 'monday_triage_recovery'
|
|
@@ -359,8 +419,10 @@ async function enrichBrief(candidate) {
|
|
|
359
419
|
// so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
|
|
360
420
|
// patient uncontacted for the week, which is the safe direction: a free-text send carries no
|
|
361
421
|
// contentSid and no idempotency key, so nothing downstream would catch a duplicate.
|
|
362
|
-
async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
|
|
422
|
+
async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
|
|
363
423
|
if (dryRun) return true;
|
|
424
|
+
if ((await sweepClaims.acquire(sweepClaimKey(candidate, now))) !== 'acquired') return false;
|
|
425
|
+
if (candidate.source === SCOPE_SOURCE) return true;
|
|
364
426
|
try {
|
|
365
427
|
const claimed = await airtableService.updateRecordByFilter(
|
|
366
428
|
Symptoms_ID,
|
|
@@ -383,7 +445,7 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
|
|
|
383
445
|
}
|
|
384
446
|
|
|
385
447
|
async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
|
|
386
|
-
if (dryRun) return;
|
|
448
|
+
if (dryRun || candidate.source === SCOPE_SOURCE) return;
|
|
387
449
|
// Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
|
|
388
450
|
// deliberate hold, and collapsing it into either would hide the failure from the ops queue.
|
|
389
451
|
let stamp;
|
|
@@ -422,6 +484,7 @@ async function runOutreachSweep({
|
|
|
422
484
|
includePreviouslySwept = false,
|
|
423
485
|
cadence = 'daily',
|
|
424
486
|
mode = 'continuity',
|
|
487
|
+
source = WEEKLY_SOURCE,
|
|
425
488
|
runOutreachTurn,
|
|
426
489
|
} = {}) {
|
|
427
490
|
if (typeof runOutreachTurn !== 'function') {
|
|
@@ -443,6 +506,12 @@ async function runOutreachSweep({
|
|
|
443
506
|
if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
|
|
444
507
|
throw new Error('mode must be continuity or monday_triage_recovery');
|
|
445
508
|
}
|
|
509
|
+
if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
|
|
510
|
+
throw new Error('source must be weekly or patient_scope');
|
|
511
|
+
}
|
|
512
|
+
if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
|
|
513
|
+
throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
|
|
514
|
+
}
|
|
446
515
|
|
|
447
516
|
const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
|
|
448
517
|
if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
|
|
@@ -460,7 +529,9 @@ async function runOutreachSweep({
|
|
|
460
529
|
const rows = await airtableService.getRecordByFilter(
|
|
461
530
|
Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
|
|
462
531
|
);
|
|
463
|
-
const { candidates, targetDays, weekday } =
|
|
532
|
+
const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
|
|
533
|
+
? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
|
|
534
|
+
: buildCandidates(rows || [], { now, cadence, mode });
|
|
464
535
|
if (cadence === 'legacy' && !targetDays.length) {
|
|
465
536
|
logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
|
|
466
537
|
return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
|
|
@@ -498,7 +569,7 @@ async function runOutreachSweep({
|
|
|
498
569
|
const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
|
|
499
570
|
const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
|
|
500
571
|
const brief = await enrichBrief(candidate);
|
|
501
|
-
if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
|
|
572
|
+
if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
|
|
502
573
|
counts.rejected++;
|
|
503
574
|
rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
|
|
504
575
|
return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
|
|
@@ -549,12 +620,13 @@ async function runOutreachSweep({
|
|
|
549
620
|
});
|
|
550
621
|
|
|
551
622
|
logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
|
|
552
|
-
return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
|
|
623
|
+
return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
|
|
553
624
|
}
|
|
554
625
|
|
|
555
626
|
module.exports = {
|
|
556
627
|
runOutreachSweep,
|
|
557
628
|
buildCandidates,
|
|
629
|
+
buildScopeCandidates,
|
|
558
630
|
rejectionReason,
|
|
559
631
|
priorSweepReason,
|
|
560
632
|
TARGET_DAYS_BY_WEEKDAY,
|
|
@@ -566,4 +638,5 @@ module.exports = {
|
|
|
566
638
|
VIEW,
|
|
567
639
|
F,
|
|
568
640
|
AUDIT,
|
|
641
|
+
sweepClaims,
|
|
569
642
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -501,6 +501,7 @@ declare module '@peopl-health/nexus' {
|
|
|
501
501
|
ran: boolean;
|
|
502
502
|
weekday: number;
|
|
503
503
|
mode?: 'continuity' | 'monday_triage_recovery';
|
|
504
|
+
source?: 'weekly' | 'patient_scope';
|
|
504
505
|
targetDays?: string[];
|
|
505
506
|
dryRun?: boolean;
|
|
506
507
|
counts: Record<string, number>;
|
|
@@ -520,6 +521,8 @@ declare module '@peopl-health/nexus' {
|
|
|
520
521
|
mode?: 'continuity' | 'monday_triage_recovery';
|
|
521
522
|
/** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
|
|
522
523
|
cadence?: 'daily' | 'legacy';
|
|
524
|
+
/** weekly: the triage questionnaire view. patient_scope: the scoped patients outside that view; requires continuity, daily and a patientScope. */
|
|
525
|
+
source?: 'weekly' | 'patient_scope';
|
|
523
526
|
}): Promise<OutreachSweepResult>;
|
|
524
527
|
|
|
525
528
|
export const routes: any;
|
|
@@ -10,6 +10,7 @@ const threadSchema = new mongoose.Schema({
|
|
|
10
10
|
run_id: { type: String, default: null },
|
|
11
11
|
nombre: { type: String, default: null },
|
|
12
12
|
stopped: { type: Boolean, default: false },
|
|
13
|
+
outreachOptOut: { type: Boolean, default: false },
|
|
13
14
|
pausedAt: { type: Date, default: null },
|
|
14
15
|
doNotAlertThrough: { type: Date, default: null },
|
|
15
16
|
review: { type: Boolean, default: false },
|