@peopl-health/nexus 5.69.0-dev.12621 → 5.69.0-dev.12623
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.
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
const moment = require('moment-timezone');
|
|
2
2
|
|
|
3
3
|
const airtableService = require('../../services/airtableService');
|
|
4
|
-
const {
|
|
4
|
+
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
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');
|
|
11
12
|
const { WriteClaimStore } = require('./writeClaimStore');
|
|
@@ -51,27 +52,9 @@ const AUDIT = {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
const WEEKLY_SOURCE = 'weekly';
|
|
54
|
-
const
|
|
55
|
-
const SOURCES = [WEEKLY_SOURCE, MONITOR_SOURCE];
|
|
55
|
+
const SCOPE_SOURCE = 'patient_scope';
|
|
56
56
|
|
|
57
|
-
const
|
|
58
|
-
const MONITOR_F = {
|
|
59
|
-
whatsapp: 'whatsapp_id',
|
|
60
|
-
lastInbound: 'last_message_patient_time',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const PATIENT_TABLE = 'estado_general';
|
|
64
|
-
const PATIENT_F = {
|
|
65
|
-
whatsapp: 'whatsapp_id',
|
|
66
|
-
firstName: 'first_name',
|
|
67
|
-
disableFollowUp: 'desactivar_seguimiento_48hrs',
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const CANONICAL_CODE = /^whatsapp:\+\d{6,}$/;
|
|
71
|
-
|
|
72
|
-
// The monitor lane has no questionnaire row to stamp, so its weekly claim lives in Mongo, where a
|
|
73
|
-
// unique key makes it atomic and an unavailable store fails closed.
|
|
74
|
-
const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 14 * 24 * 60 * 60 * 1000 });
|
|
57
|
+
const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
|
|
75
58
|
|
|
76
59
|
const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
|
|
77
60
|
const CONTACT_SENT = {
|
|
@@ -227,64 +210,18 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
|
|
|
227
210
|
return { candidates, targetDays, weekday, cadence, mode };
|
|
228
211
|
}
|
|
229
212
|
|
|
230
|
-
function
|
|
231
|
-
return `DATETIME_PARSE("${date.toISOString()}")`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function monitorPrefilter(now) {
|
|
235
|
-
const oldest = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS + 1, 'days').toDate();
|
|
236
|
-
const newest = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS - 1, 'days').toDate();
|
|
237
|
-
return `AND(IS_AFTER({${MONITOR_F.lastInbound}}, ${airtableInstant(oldest)}), `
|
|
238
|
-
+ `IS_BEFORE({${MONITOR_F.lastInbound}}, ${airtableInstant(newest)}))`;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// A number can carry several monitor rows; any one of them outside prod, outside the programme or
|
|
242
|
-
// paused rules the number out, so a stale duplicate cannot admit it.
|
|
243
|
-
const MONITOR_DENY = 'OR({tipo} != "prod", NOT({is_program}), {stopped})';
|
|
244
|
-
|
|
245
|
-
/* Patients the weekly lane does not own: anyone in the questionnaire view, or with a questionnaire
|
|
246
|
-
* row this week, belongs to that lane and its stamps. Names come from estado_general because the
|
|
247
|
-
* monitor's own name is a formula that falls back to "Desconocido". A number carrying two names is
|
|
248
|
-
* a shared handset, and a message addressed to one of them is skipped rather than guessed. */
|
|
249
|
-
function buildMonitorCandidates({ monitorRows, patientRows, excludedCodes }) {
|
|
250
|
-
const newestByCode = new Map();
|
|
251
|
-
for (const row of monitorRows) {
|
|
252
|
-
const code = String(first(row[MONITOR_F.whatsapp]) || '').trim();
|
|
253
|
-
if (!CANONICAL_CODE.test(code) || excludedCodes.has(code)) continue;
|
|
254
|
-
const seen = newestByCode.get(code);
|
|
255
|
-
if (!seen || new Date(row[MONITOR_F.lastInbound] || 0) > new Date(seen[MONITOR_F.lastInbound] || 0)) {
|
|
256
|
-
newestByCode.set(code, row);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const patientsByCode = new Map();
|
|
261
|
-
for (const row of patientRows) {
|
|
262
|
-
const code = String(first(row[PATIENT_F.whatsapp]) || '').trim();
|
|
263
|
-
if (!newestByCode.has(code)) continue;
|
|
264
|
-
if (!patientsByCode.has(code)) patientsByCode.set(code, { names: new Map(), followUpDisabled: false });
|
|
265
|
-
const patient = patientsByCode.get(code);
|
|
266
|
-
const name = String(first(row[PATIENT_F.firstName]) || '').trim();
|
|
267
|
-
if (name) patient.names.set(name.toLowerCase().replace(/\s+/g, ' '), name);
|
|
268
|
-
if (row[PATIENT_F.disableFollowUp] === true) patient.followUpDisabled = true;
|
|
269
|
-
}
|
|
270
|
-
|
|
213
|
+
function buildScopeCandidates({ threads, excludedCodes }) {
|
|
271
214
|
const candidates = [];
|
|
272
|
-
for (const [code,
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
logger.info('[outreachSweep] monitor candidate skipped — no single patient name', {
|
|
276
|
-
recordId: row._recordId, names: patient?.names.size || 0,
|
|
277
|
-
});
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
215
|
+
for (const thread of new Map(threads.map((t) => [t.code, t])).values()) {
|
|
216
|
+
const name = String(thread.nombre || '').trim().split(/\s+/)[0];
|
|
217
|
+
if (excludedCodes.has(thread.code) || !name) continue;
|
|
280
218
|
candidates.push({
|
|
281
|
-
source:
|
|
282
|
-
patientId: null,
|
|
283
|
-
recordId:
|
|
284
|
-
code,
|
|
285
|
-
name
|
|
219
|
+
source: SCOPE_SOURCE,
|
|
220
|
+
patientId: thread.patient_id || null,
|
|
221
|
+
recordId: String(thread._id),
|
|
222
|
+
code: thread.code,
|
|
223
|
+
name,
|
|
286
224
|
reason: 'no_triage_history',
|
|
287
|
-
followUpDisabled: patient.followUpDisabled,
|
|
288
225
|
rows: [],
|
|
289
226
|
brief: {
|
|
290
227
|
reason: 'no hay un triaje contestado que sirva como ancla clínica',
|
|
@@ -297,36 +234,24 @@ function buildMonitorCandidates({ monitorRows, patientRows, excludedCodes }) {
|
|
|
297
234
|
return candidates;
|
|
298
235
|
}
|
|
299
236
|
|
|
300
|
-
async function
|
|
301
|
-
const weekStart = startOfLocalWeek(now).
|
|
302
|
-
const [
|
|
237
|
+
async function loadScopeCandidates({ now, viewRows, patientScope }) {
|
|
238
|
+
const weekStart = startOfLocalWeek(now).toISOString();
|
|
239
|
+
const [threads, weekRows] = await Promise.all([
|
|
240
|
+
Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
|
|
303
241
|
airtableService.getRecordByFilter(
|
|
304
|
-
|
|
305
|
-
),
|
|
306
|
-
airtableService.getRecordByFilter(Monitoreo_ID, MONITOR_TABLE, MONITOR_DENY, null, [MONITOR_F.whatsapp]),
|
|
307
|
-
airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F)),
|
|
308
|
-
airtableService.getRecordByFilter(
|
|
309
|
-
Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, ${airtableInstant(weekStart)}))`, null, [F.whatsapp],
|
|
242
|
+
Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
|
|
310
243
|
),
|
|
311
244
|
]);
|
|
312
|
-
const excludedCodes = new Set([
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
].map((code) => String(code || '').trim()).filter(Boolean));
|
|
316
|
-
return buildMonitorCandidates({ monitorRows: monitorRows || [], patientRows: patientRows || [], excludedCodes });
|
|
245
|
+
const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
|
|
246
|
+
.map((row) => String(first(row[F.whatsapp]) || '').trim()));
|
|
247
|
+
return buildScopeCandidates({ threads: threads || [], excludedCodes });
|
|
317
248
|
}
|
|
318
249
|
|
|
319
250
|
function sweepClaimKey(candidate, now) {
|
|
320
251
|
return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
|
|
321
252
|
}
|
|
322
253
|
|
|
323
|
-
function sweepReasonFromStamps(stamps) {
|
|
324
|
-
if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
|
|
325
|
-
return stamps.length ? 'already_swept_this_week' : null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
254
|
function opsOptedOut(candidate, { now }) {
|
|
329
|
-
if (candidate.followUpDisabled) return 'patient_follow_up_disabled';
|
|
330
255
|
const weekStart = startOfLocalWeek(now);
|
|
331
256
|
for (const row of candidate.rows) {
|
|
332
257
|
if (first(row[F.disableFollowUp]) === true) return 'patient_follow_up_disabled';
|
|
@@ -340,12 +265,9 @@ function opsOptedOut(candidate, { now }) {
|
|
|
340
265
|
// Patient-scoped, not row-scoped: a patient with two questionnaires this week is one person, and
|
|
341
266
|
// the old automation's per-row stamp is what let her be contacted twice.
|
|
342
267
|
function priorSweepReason(candidate) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
async function priorMonitorSweepReason(candidate, { now }) {
|
|
347
|
-
const claim = await sweepClaims.get(sweepClaimKey(candidate, now));
|
|
348
|
-
return sweepReasonFromStamps(claim?.stamp ? [claim.stamp] : []);
|
|
268
|
+
const stamps = candidate.rows.map((row) => String(row[F.review] || '').trim()).filter(Boolean);
|
|
269
|
+
if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
|
|
270
|
+
return stamps.length ? 'already_swept_this_week' : null;
|
|
349
271
|
}
|
|
350
272
|
|
|
351
273
|
async function mondayTriageStillPending(candidate) {
|
|
@@ -364,10 +286,11 @@ async function rejectionReason(candidate, {
|
|
|
364
286
|
} = {}) {
|
|
365
287
|
const opsReason = opsOptedOut(candidate, { now });
|
|
366
288
|
if (opsReason) return opsReason;
|
|
289
|
+
if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'patient_follow_up_disabled';
|
|
367
290
|
if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
|
|
368
291
|
if (!includePreviouslySwept) {
|
|
369
|
-
const sweptReason = candidate.source ===
|
|
370
|
-
? await
|
|
292
|
+
const sweptReason = candidate.source === SCOPE_SOURCE
|
|
293
|
+
? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
|
|
371
294
|
: priorSweepReason(candidate);
|
|
372
295
|
if (sweptReason) return sweptReason;
|
|
373
296
|
|
|
@@ -488,14 +411,9 @@ async function enrichBrief(candidate) {
|
|
|
488
411
|
// patient uncontacted for the week, which is the safe direction: a free-text send carries no
|
|
489
412
|
// contentSid and no idempotency key, so nothing downstream would catch a duplicate.
|
|
490
413
|
async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
|
|
491
|
-
if (dryRun) return
|
|
492
|
-
if (candidate.source ===
|
|
493
|
-
|
|
494
|
-
stamp: `${weekdayLabel}_en curso`, recordId: candidate.recordId,
|
|
495
|
-
});
|
|
496
|
-
if (claim === 'acquired') return null;
|
|
497
|
-
logger.info('[outreachSweep] monitor claim not acquired', { recordId: candidate.recordId, claim });
|
|
498
|
-
return claim === 'taken' ? 'claim_lost' : 'claim_unavailable';
|
|
414
|
+
if (dryRun) return true;
|
|
415
|
+
if (candidate.source === SCOPE_SOURCE) {
|
|
416
|
+
return (await sweepClaims.acquire(sweepClaimKey(candidate, now))) === 'acquired';
|
|
499
417
|
}
|
|
500
418
|
try {
|
|
501
419
|
const claimed = await airtableService.updateRecordByFilter(
|
|
@@ -507,39 +425,27 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
|
|
|
507
425
|
);
|
|
508
426
|
if (!claimed?.length) {
|
|
509
427
|
logger.info('[outreachSweep] candidate already claimed by another run', { recordId: candidate.recordId });
|
|
510
|
-
return
|
|
428
|
+
return false;
|
|
511
429
|
}
|
|
512
|
-
return
|
|
430
|
+
return true;
|
|
513
431
|
} catch (error) {
|
|
514
432
|
logger.warn('[outreachSweep] claim failed; skipping rather than risking a double send', {
|
|
515
433
|
recordId: candidate.recordId, error: error.message,
|
|
516
434
|
});
|
|
517
|
-
return
|
|
435
|
+
return false;
|
|
518
436
|
}
|
|
519
437
|
}
|
|
520
438
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (outcome.
|
|
527
|
-
if (outcome.
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun, now }) {
|
|
532
|
-
if (dryRun) return;
|
|
533
|
-
const stamp = outcomeStamp(outcome, weekdayLabel);
|
|
534
|
-
if (candidate.source === MONITOR_SOURCE) {
|
|
535
|
-
await sweepClaims.set(sweepClaimKey(candidate, now), {
|
|
536
|
-
stamp,
|
|
537
|
-
recordId: candidate.recordId,
|
|
538
|
-
rationale: outcome.rationale || outcome.error || '',
|
|
539
|
-
turnId: outcome.turnId || '',
|
|
540
|
-
});
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
439
|
+
async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
|
|
440
|
+
if (dryRun || candidate.source === SCOPE_SOURCE) return;
|
|
441
|
+
// Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
|
|
442
|
+
// deliberate hold, and collapsing it into either would hide the failure from the ops queue.
|
|
443
|
+
let stamp;
|
|
444
|
+
if (outcome.error) stamp = `${weekdayLabel}_error`;
|
|
445
|
+
else if (outcome.delivered) stamp = `${weekdayLabel}_entregado`;
|
|
446
|
+
else if (outcome.accepted) stamp = `${weekdayLabel}_enviado`;
|
|
447
|
+
else if (outcome.decision === 'write') stamp = `${weekdayLabel}_no entregado`;
|
|
448
|
+
else stamp = `${weekdayLabel}_omitido`;
|
|
543
449
|
const where = `RECORD_ID() = "${candidate.recordId}"`;
|
|
544
450
|
await airtableService.updateRecordByFilter(Symptoms_ID, TABLE, where, {
|
|
545
451
|
[F.review]: stamp,
|
|
@@ -592,19 +498,16 @@ async function runOutreachSweep({
|
|
|
592
498
|
if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
|
|
593
499
|
throw new Error('mode must be continuity or monday_triage_recovery');
|
|
594
500
|
}
|
|
595
|
-
if (!
|
|
596
|
-
throw new Error('source must be weekly or
|
|
501
|
+
if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
|
|
502
|
+
throw new Error('source must be weekly or patient_scope');
|
|
597
503
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (source === MONITOR_SOURCE
|
|
601
|
-
&& (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
|
|
602
|
-
throw new Error('the message_monitor source requires continuity mode, daily cadence and a patientScope');
|
|
504
|
+
if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
|
|
505
|
+
throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
|
|
603
506
|
}
|
|
604
507
|
|
|
605
508
|
const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
|
|
606
509
|
if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
|
|
607
|
-
return { ran: false, weekday: localWeekday,
|
|
510
|
+
return { ran: false, weekday: localWeekday, reason: 'not_monday', counts: {} };
|
|
608
511
|
}
|
|
609
512
|
|
|
610
513
|
// Triage owns the morning. This is deliberately enforced below the cron so a manual invocation
|
|
@@ -612,25 +515,25 @@ async function runOutreachSweep({
|
|
|
612
515
|
if (isWithinTriageProtectionWindow(now)) {
|
|
613
516
|
const weekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
|
|
614
517
|
logger.info('[outreachSweep] triage protection window — nothing sent', { weekday });
|
|
615
|
-
return { ran: false, weekday,
|
|
518
|
+
return { ran: false, weekday, reason: 'triage_protection_window', counts: {} };
|
|
616
519
|
}
|
|
617
520
|
|
|
618
521
|
const rows = await airtableService.getRecordByFilter(
|
|
619
522
|
Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
|
|
620
523
|
);
|
|
621
|
-
const { candidates, targetDays, weekday } = source ===
|
|
622
|
-
? { candidates: await
|
|
524
|
+
const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
|
|
525
|
+
? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
|
|
623
526
|
: buildCandidates(rows || [], { now, cadence, mode });
|
|
624
527
|
if (cadence === 'legacy' && !targetDays.length) {
|
|
625
528
|
logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
|
|
626
|
-
return { ran: false, weekday,
|
|
529
|
+
return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
|
|
627
530
|
}
|
|
628
531
|
|
|
629
532
|
// The scheduler is not the guard. An operator running the sweep by hand at 03:00 must not wake
|
|
630
533
|
// every candidate, so the window is checked here as well as by whatever invoked us.
|
|
631
534
|
if (!isWithinHourWindow(now, PATIENT_CONTACT_WINDOW)) {
|
|
632
535
|
logger.info('[outreachSweep] outside patient contact hours — nothing sent', { weekday });
|
|
633
|
-
return { ran: false, weekday,
|
|
536
|
+
return { ran: false, weekday, reason: 'quiet_hours', counts: {} };
|
|
634
537
|
}
|
|
635
538
|
|
|
636
539
|
// An explicit empty scope means "nobody", not "scope omitted". This matters for a canary
|
|
@@ -658,11 +561,10 @@ async function runOutreachSweep({
|
|
|
658
561
|
const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
|
|
659
562
|
const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
|
|
660
563
|
const brief = await enrichBrief(candidate);
|
|
661
|
-
|
|
662
|
-
if (claimLost) {
|
|
564
|
+
if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
|
|
663
565
|
counts.rejected++;
|
|
664
|
-
rejectedBy
|
|
665
|
-
return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason:
|
|
566
|
+
rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
|
|
567
|
+
return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
|
|
666
568
|
}
|
|
667
569
|
try {
|
|
668
570
|
const outcome = await runOutreachTurn(candidate.code, {
|
|
@@ -672,9 +574,7 @@ async function runOutreachSweep({
|
|
|
672
574
|
dryRun,
|
|
673
575
|
reminderType: 'seguimiento',
|
|
674
576
|
reminderPrefix: mode === 'monday_triage_recovery' ? 'recovery' : null,
|
|
675
|
-
triggeredBy: candidate.
|
|
676
|
-
? `seguimiento:mm:${candidate.recordId}`
|
|
677
|
-
: `seguimiento:${candidate.recordId}`,
|
|
577
|
+
triggeredBy: `seguimiento:${candidate.recordId}`,
|
|
678
578
|
});
|
|
679
579
|
counts.run++;
|
|
680
580
|
if (outcome.decision === 'skipped') {
|
|
@@ -684,20 +584,19 @@ async function runOutreachSweep({
|
|
|
684
584
|
outcome: { ...outcome, decision: 'hold', rationale: outcome.rationale || outcome.holdReason || 'turn skipped' },
|
|
685
585
|
weekdayLabel,
|
|
686
586
|
dryRun,
|
|
687
|
-
now,
|
|
688
587
|
});
|
|
689
588
|
return { code: candidate.code, recordId: candidate.recordId, ...outcome };
|
|
690
589
|
}
|
|
691
590
|
counts[outcome.decision === 'write' ? 'write' : 'hold']++;
|
|
692
591
|
if (outcome.accepted) counts.accepted++;
|
|
693
592
|
if (outcome.delivered) counts.delivered++;
|
|
694
|
-
await recordOutcome({ candidate, outcome, weekdayLabel, dryRun
|
|
593
|
+
await recordOutcome({ candidate, outcome, weekdayLabel, dryRun });
|
|
695
594
|
return { code: candidate.code, recordId: candidate.recordId, dryRun, brief, ...outcome };
|
|
696
595
|
} catch (error) {
|
|
697
596
|
counts.error++;
|
|
698
597
|
logger.error('[outreachSweep] outreach turn failed', { code: candidate.code, error: error.message });
|
|
699
598
|
try {
|
|
700
|
-
await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun
|
|
599
|
+
await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun });
|
|
701
600
|
} catch (auditError) {
|
|
702
601
|
// An audit-schema outage must be loud, but it must not abort the other patients in this
|
|
703
602
|
// bounded batch. The pre-send claim remains `_en curso`, which the next sweep surfaces as
|
|
@@ -719,7 +618,7 @@ async function runOutreachSweep({
|
|
|
719
618
|
module.exports = {
|
|
720
619
|
runOutreachSweep,
|
|
721
620
|
buildCandidates,
|
|
722
|
-
|
|
621
|
+
buildScopeCandidates,
|
|
723
622
|
rejectionReason,
|
|
724
623
|
priorSweepReason,
|
|
725
624
|
TARGET_DAYS_BY_WEEKDAY,
|
|
@@ -730,8 +629,6 @@ module.exports = {
|
|
|
730
629
|
mondayTriageStillPending,
|
|
731
630
|
VIEW,
|
|
732
631
|
F,
|
|
733
|
-
MONITOR_F,
|
|
734
|
-
PATIENT_F,
|
|
735
632
|
AUDIT,
|
|
736
633
|
sweepClaims,
|
|
737
634
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -501,7 +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' | '
|
|
504
|
+
source?: 'weekly' | 'patient_scope';
|
|
505
505
|
targetDays?: string[];
|
|
506
506
|
dryRun?: boolean;
|
|
507
507
|
counts: Record<string, number>;
|
|
@@ -521,8 +521,8 @@ declare module '@peopl-health/nexus' {
|
|
|
521
521
|
mode?: 'continuity' | 'monday_triage_recovery';
|
|
522
522
|
/** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
|
|
523
523
|
cadence?: 'daily' | 'legacy';
|
|
524
|
-
/** weekly: the triage questionnaire view.
|
|
525
|
-
source?: 'weekly' | '
|
|
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';
|
|
526
526
|
}): Promise<OutreachSweepResult>;
|
|
527
527
|
|
|
528
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 },
|