@peopl-health/nexus 5.54.0-dev.7991 → 5.54.0-dev.8011
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/config/safetyNetConfig.js +31 -8
- package/lib/clinical/services/assistantService.js +2 -2
- package/lib/clinical/services/pausedSafetyNetService.js +38 -65
- package/lib/clinical/services/safetyNetPledgeService.js +41 -14
- package/lib/core/NexusMessaging.js +2 -2
- package/lib/models/safetyNetPledgeModel.js +0 -1
- package/lib/utils/scheduleUtils.js +24 -1
- package/package.json +1 -1
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
2
2
|
|
|
3
|
-
const DEFAULT_OFFER_WINDOW_MS =
|
|
4
|
-
const DEFAULT_HUMAN_QUIET_MS = 60 * 60 * 1000;
|
|
3
|
+
const DEFAULT_OFFER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const DEFAULT_HUMAN_QUIET_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const DEFAULT_ACTIVITY_QUIET_MS = 2 * 60 * 60 * 1000;
|
|
5
6
|
const DEFAULT_TAP_WINDOW_MS = 30 * 60 * 1000;
|
|
6
|
-
const DEFAULT_PLEDGE_MS =
|
|
7
|
+
const DEFAULT_PLEDGE_MS = 8 * 60 * 60 * 1000;
|
|
7
8
|
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
8
9
|
|
|
9
10
|
const CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
|
|
10
11
|
|
|
11
12
|
const SAFETY_NET_SOURCE = 'paused_safety_net';
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const PATIENT_FACING_ORIGINS = ['whatsapp_platform', 'assistant'];
|
|
15
|
+
const HUMAN_ORIGINS = ['whatsapp_platform'];
|
|
16
|
+
const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
|
|
17
|
+
const RECEIVED_STATUSES = ['delivered', 'read'];
|
|
18
|
+
|
|
19
|
+
const anyReplyFilter = (code, since) => ({
|
|
14
20
|
numero: code,
|
|
15
21
|
from_me: true,
|
|
16
22
|
content_sid: null,
|
|
17
|
-
origin: { $
|
|
23
|
+
origin: { $in: PATIENT_FACING_ORIGINS },
|
|
24
|
+
'statusInfo.status': { $nin: UNDELIVERED_STATUSES },
|
|
18
25
|
triggeredBy: { $ne: SAFETY_NET_SOURCE },
|
|
19
26
|
createdAt: { $gt: since }
|
|
20
27
|
});
|
|
21
28
|
|
|
29
|
+
const humanReplyFilter = (code, since) => ({
|
|
30
|
+
...anyReplyFilter(code, since),
|
|
31
|
+
origin: { $in: HUMAN_ORIGINS }
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const settledHumanReplyFilter = (code, since, unconfirmedBefore) => ({
|
|
35
|
+
...humanReplyFilter(code, since),
|
|
36
|
+
$or: [
|
|
37
|
+
{ 'statusInfo.status': { $in: RECEIVED_STATUSES } },
|
|
38
|
+
{ createdAt: { $lte: unconfirmedBefore } }
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
|
|
22
42
|
const CHOICE_LABEL_KEYS = {
|
|
23
|
-
|
|
24
|
-
callback: 'PAUSED_SAFETY_NET_CALLBACK_LABEL',
|
|
25
|
-
later: 'PAUSED_SAFETY_NET_LATER_LABEL'
|
|
43
|
+
callback: 'PAUSED_SAFETY_NET_CALLBACK_LABEL'
|
|
26
44
|
};
|
|
27
45
|
|
|
28
46
|
let injected = {};
|
|
@@ -49,15 +67,20 @@ const duration = (key, envKey, fallback) => {
|
|
|
49
67
|
module.exports = {
|
|
50
68
|
CONTACT_WINDOW,
|
|
51
69
|
SAFETY_NET_SOURCE,
|
|
70
|
+
DEFAULT_PLEDGE_MS,
|
|
71
|
+
anyReplyFilter,
|
|
52
72
|
humanReplyFilter,
|
|
73
|
+
settledHumanReplyFilter,
|
|
53
74
|
CHOICE_LABEL_KEYS,
|
|
54
75
|
configureSafetyNet,
|
|
55
76
|
_resetSafetyNetConfig,
|
|
56
77
|
getSafetyNetContentSid: () => text('contentSid', 'PAUSED_SAFETY_NET_CONTENT_SID'),
|
|
78
|
+
getSafetyNetUnassignedContentSid: () => text('unassignedContentSid', 'PAUSED_SAFETY_NET_UNASSIGNED_CONTENT_SID'),
|
|
57
79
|
getSafetyNetPhone: () => text('phone', 'PAUSED_SAFETY_NET_PHONE'),
|
|
58
80
|
getSafetyNetLabel: (choice) => text(`${choice}Label`, CHOICE_LABEL_KEYS[choice]),
|
|
59
81
|
getOfferWindowMs: () => duration('offerWindowMs', 'PAUSED_SAFETY_NET_WINDOW_MS', DEFAULT_OFFER_WINDOW_MS),
|
|
60
82
|
getHumanQuietMs: () => duration('humanQuietMs', 'SAFETY_NET_HUMAN_QUIET_MS', DEFAULT_HUMAN_QUIET_MS),
|
|
83
|
+
getActivityQuietMs: () => duration('activityQuietMs', 'SAFETY_NET_ACTIVITY_QUIET_MS', DEFAULT_ACTIVITY_QUIET_MS),
|
|
61
84
|
getTapWindowMs: () => duration('tapWindowMs', 'SAFETY_NET_TAP_WINDOW_MS', DEFAULT_TAP_WINDOW_MS),
|
|
62
85
|
getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS),
|
|
63
86
|
getSweepIntervalMs: () => duration('sweepIntervalMs', 'SAFETY_NET_SWEEP_INTERVAL_MS', DEFAULT_SWEEP_INTERVAL_MS)
|
|
@@ -15,7 +15,7 @@ const { cleanupFiles } = require('../../helpers/filesHelper.js');
|
|
|
15
15
|
const { getMessages, countMessages } = require('../../services/messageService');
|
|
16
16
|
|
|
17
17
|
const { getAssistantById } = require('./assistantResolver');
|
|
18
|
-
const { coverUnansweredPatient } = require('./pausedSafetyNetService');
|
|
18
|
+
const { PAUSED, coverUnansweredPatient } = require('./pausedSafetyNetService');
|
|
19
19
|
|
|
20
20
|
const MEDIA_BACKLOG_LIMIT = 10;
|
|
21
21
|
|
|
@@ -112,7 +112,7 @@ const preProcessMessagesCore = async (code, message_ = null, thread) => {
|
|
|
112
112
|
return { shouldProcess: false, messages: null, timings, pendingMediaUpdates: [] };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
if (thread.stopped) await coverUnansweredPatient({ code });
|
|
115
|
+
if (thread.stopped) await coverUnansweredPatient({ code, reason: PAUSED });
|
|
116
116
|
|
|
117
117
|
const backlog = await fetchMediaBacklog(code, lastMessage[0]);
|
|
118
118
|
const replies = [...backlog, ...lastMessage];
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
const {
|
|
2
|
-
CONTACT_WINDOW,
|
|
3
2
|
SAFETY_NET_SOURCE,
|
|
3
|
+
getActivityQuietMs,
|
|
4
4
|
getHumanQuietMs,
|
|
5
5
|
getOfferWindowMs,
|
|
6
|
-
getPledgeMs,
|
|
7
6
|
getSafetyNetContentSid,
|
|
8
7
|
getSafetyNetLabel,
|
|
9
8
|
getSafetyNetPhone,
|
|
9
|
+
getSafetyNetUnassignedContentSid,
|
|
10
10
|
getTapWindowMs
|
|
11
11
|
} = require('../config/safetyNetConfig');
|
|
12
12
|
const { logger } = require('../../utils/logger');
|
|
@@ -15,28 +15,23 @@ const { acquireSendClaim, completeSendClaim, releaseSendClaim } = require('../..
|
|
|
15
15
|
|
|
16
16
|
const { normalizePayload: normalize, registerInteractiveRoute, unregisterInteractiveRoute } = require('../../core/interactiveRouteService');
|
|
17
17
|
const { getMessages } = require('../../services/messageService');
|
|
18
|
-
const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
|
|
19
18
|
|
|
20
19
|
const { WriteClaimStore } = require('./writeClaimStore');
|
|
21
|
-
const {
|
|
22
|
-
const { humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
|
|
20
|
+
const { recordPausedThreadMessage, recordUnresolvedRequest } = require('./clinicalAirtableService');
|
|
21
|
+
const { anyoneRepliedSince, humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
|
|
23
22
|
|
|
24
23
|
const getMessaging = () => require('../../core/NexusMessaging');
|
|
25
24
|
|
|
26
25
|
const SOURCE = SAFETY_NET_SOURCE;
|
|
27
26
|
const PAYLOAD_PREFIX = 'peopl_safetynet_';
|
|
28
|
-
const CHOICES = ['
|
|
27
|
+
const CHOICES = ['callback'];
|
|
28
|
+
const PAUSED = 'paused';
|
|
29
|
+
const UNASSIGNED = 'unassigned';
|
|
29
30
|
const REQUEST_CATEGORY = 'contact_team_member';
|
|
30
|
-
const CAN_WAIT_NOTE = 'El paciente indicó que su solicitud puede esperar.';
|
|
31
|
-
const ATTENDED_STATUS = 'sin atender';
|
|
32
31
|
|
|
33
32
|
const REQUEST_COPY = {
|
|
34
|
-
urgent: {
|
|
35
|
-
question: 'El paciente indicó que su situación es urgente',
|
|
36
|
-
details: (code) => `El paciente ${code} recibió el aviso de bot en pausa y eligió "urgencia". Se le dio el número para llamar. Revisar la conversación ahora.`
|
|
37
|
-
},
|
|
38
33
|
callback: {
|
|
39
|
-
question: 'El paciente pidió que el equipo lo contacte
|
|
34
|
+
question: 'El paciente pidió que el equipo lo contacte',
|
|
40
35
|
details: (code) => `El paciente ${code} recibió el aviso de bot en pausa y pidió que lo contacten. Se le prometió contacto; revisar la conversación.`
|
|
41
36
|
}
|
|
42
37
|
};
|
|
@@ -81,15 +76,25 @@ function matchSafetyNetChoice(payload, title) {
|
|
|
81
76
|
}) || null;
|
|
82
77
|
}
|
|
83
78
|
|
|
79
|
+
const dialledNumbers = (template) => Object.values(template?.types ?? {})
|
|
80
|
+
.flatMap((type) => type?.actions ?? [])
|
|
81
|
+
.filter((action) => action?.type === 'PHONE_NUMBER')
|
|
82
|
+
.map((action) => action?.phone);
|
|
83
|
+
|
|
84
84
|
async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
85
85
|
const phone = getSafetyNetPhone();
|
|
86
86
|
if (!phone || phoneCheckedFor === contentSid) return;
|
|
87
87
|
phoneCheckedFor = contentSid;
|
|
88
88
|
try {
|
|
89
|
-
const
|
|
89
|
+
const provider = getMessaging().requireProvider();
|
|
90
90
|
const digits = (value) => String(value ?? '').replace(/\D/g, '');
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
const wanted = digits(phone);
|
|
92
|
+
|
|
93
|
+
if (dialledNumbers(await provider.getTemplate(contentSid)).map(digits).includes(wanted)) return;
|
|
94
|
+
|
|
95
|
+
const body = await provider.renderTemplate(contentSid);
|
|
96
|
+
if (body && !digits(body).includes(wanted)) {
|
|
97
|
+
logger.error('[PausedSafetyNet] The configured phone number is absent from the template', { contentSid });
|
|
93
98
|
}
|
|
94
99
|
} catch (error) {
|
|
95
100
|
phoneCheckedFor = null;
|
|
@@ -97,13 +102,19 @@ async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
|
|
105
|
+
function templateFor(reason) {
|
|
106
|
+
if (reason !== UNASSIGNED) return getSafetyNetContentSid();
|
|
107
|
+
return getSafetyNetUnassignedContentSid() || getSafetyNetContentSid();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function offerSafetyNet({ code, reason = PAUSED }) {
|
|
101
111
|
try {
|
|
102
|
-
|
|
103
|
-
if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
112
|
+
if (!code || !getSafetyNetContentSid() || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
104
113
|
if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
|
|
105
114
|
if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
|
|
115
|
+
if (await anyoneRepliedSince(code, new Date(Date.now() - getActivityQuietMs()))) return { sent: false, reason: 'conversation_active' };
|
|
106
116
|
|
|
117
|
+
const contentSid = templateFor(reason);
|
|
107
118
|
await warnIfPhoneDiffersFromTemplate(contentSid);
|
|
108
119
|
|
|
109
120
|
const result = await getMessaging().sendMessage({ code, contentSid, triggeredBy: SOURCE });
|
|
@@ -118,11 +129,13 @@ async function offerSafetyNet({ code }) {
|
|
|
118
129
|
}
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
const offerContentSids = () => [getSafetyNetContentSid(), getSafetyNetUnassignedContentSid()].filter(Boolean);
|
|
133
|
+
|
|
121
134
|
async function offerIsLive(code) {
|
|
122
135
|
try {
|
|
123
136
|
const since = new Date(Date.now() - getOfferWindowMs());
|
|
124
137
|
const [sent] = await getMessages(
|
|
125
|
-
{ numero: code, from_me: true, content_sid:
|
|
138
|
+
{ numero: code, from_me: true, content_sid: { $in: offerContentSids() }, createdAt: { $gt: since } },
|
|
126
139
|
{ sort: { createdAt: -1 }, limit: 1 }
|
|
127
140
|
);
|
|
128
141
|
return Boolean(sent);
|
|
@@ -140,7 +153,7 @@ async function unansweredText(code) {
|
|
|
140
153
|
return written ? written.plainBody || written.body : null;
|
|
141
154
|
}
|
|
142
155
|
|
|
143
|
-
async function coverUnansweredPatient({ code }) {
|
|
156
|
+
async function coverUnansweredPatient({ code, reason = PAUSED }) {
|
|
144
157
|
if (!code) return;
|
|
145
158
|
|
|
146
159
|
const claim = await acquireSendClaim({ idempotencyKey: `safetynet-cover:${code}` });
|
|
@@ -148,7 +161,7 @@ async function coverUnansweredPatient({ code }) {
|
|
|
148
161
|
|
|
149
162
|
const results = await Promise.allSettled([
|
|
150
163
|
unansweredText(code).then((text) => recordPausedThreadMessage({ code, text })),
|
|
151
|
-
offerSafetyNet({ code }),
|
|
164
|
+
offerSafetyNet({ code, reason }),
|
|
152
165
|
]);
|
|
153
166
|
|
|
154
167
|
const covered = results.every((r) => r.status === 'fulfilled');
|
|
@@ -168,32 +181,8 @@ async function fileUrgentRequest(code, choice) {
|
|
|
168
181
|
});
|
|
169
182
|
}
|
|
170
183
|
|
|
171
|
-
async function markCanWait(code) {
|
|
172
|
-
const { requests = [] } = await getPatientTasks(code);
|
|
173
|
-
const target = requests.find((request) => request.status === ATTENDED_STATUS
|
|
174
|
-
&& request.category === REQUEST_CATEGORY
|
|
175
|
-
&& String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
|
|
176
|
-
if (!target) return false;
|
|
177
|
-
|
|
178
|
-
await updatePatientTask({
|
|
179
|
-
code,
|
|
180
|
-
kind: 'requests',
|
|
181
|
-
recordId: target.recordId,
|
|
182
|
-
changes: {
|
|
183
|
-
urgency: 'routine',
|
|
184
|
-
note: [target.note, CAN_WAIT_NOTE].filter(Boolean).join('\n')
|
|
185
|
-
},
|
|
186
|
-
updatedBy: SOURCE,
|
|
187
|
-
source: SOURCE
|
|
188
|
-
});
|
|
189
|
-
return true;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
184
|
async function applyChoice(code, choice) {
|
|
193
|
-
if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
|
|
194
|
-
|
|
195
185
|
const queued = await fileUrgentRequest(code, choice);
|
|
196
|
-
if (choice !== 'callback') return { queued, pledge: null };
|
|
197
186
|
if (!queued) return { queued, pledge: null };
|
|
198
187
|
|
|
199
188
|
try {
|
|
@@ -218,30 +207,12 @@ function ackFor(choice, outcome) {
|
|
|
218
207
|
return withPhone('No pudimos registrar tu solicitud.', 'Por favor llámanos al {phone} para que te atendamos.');
|
|
219
208
|
}
|
|
220
209
|
|
|
221
|
-
if (choice === 'urgent') {
|
|
222
|
-
return withPhone('Entendido, ya avisamos al equipo.', 'Si es una urgencia médica, llámanos ahora al {phone}.');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (choice === 'later') {
|
|
226
|
-
return outcome.downgraded
|
|
227
|
-
? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
|
|
228
|
-
: withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
210
|
if (!outcome.pledge) {
|
|
232
211
|
return withPhone('Listo, ya avisamos al equipo y te van a contactar.', 'Si te sientes peor antes, llámanos al {phone}.');
|
|
233
212
|
}
|
|
234
213
|
|
|
235
|
-
if (outcome.pledge.deferredToWindow) {
|
|
236
|
-
return withPhone(
|
|
237
|
-
`Listo, ya avisamos al equipo. Te van a contactar en cuanto abran, a partir de las ${CONTACT_WINDOW.startHour}:00.`,
|
|
238
|
-
'Si es una urgencia médica, llámanos ahora al {phone}.'
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const minutes = Math.round(getPledgeMs() / 60000);
|
|
243
214
|
return withPhone(
|
|
244
|
-
|
|
215
|
+
'Listo. Un miembro del equipo te va a contactar a la brevedad.',
|
|
245
216
|
'Si te sientes peor antes, llámanos al {phone}.'
|
|
246
217
|
);
|
|
247
218
|
}
|
|
@@ -310,6 +281,8 @@ module.exports = {
|
|
|
310
281
|
coverUnansweredPatient,
|
|
311
282
|
disarmSafetyNet,
|
|
312
283
|
offerSafetyNet,
|
|
284
|
+
PAUSED,
|
|
285
|
+
UNASSIGNED,
|
|
313
286
|
handleSafetyNetTap,
|
|
314
287
|
matchSafetyNetChoice,
|
|
315
288
|
registerSafetyNetRoutes,
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
CONTACT_WINDOW, DEFAULT_PLEDGE_MS, anyReplyFilter, getPledgeMs, getSweepIntervalMs, humanReplyFilter,
|
|
3
|
+
settledHumanReplyFilter
|
|
4
|
+
} = require('../config/safetyNetConfig');
|
|
2
5
|
const { createIntervalSweeper } = require('../../utils/intervalSweeper');
|
|
3
6
|
const { logger } = require('../../utils/logger');
|
|
4
|
-
const {
|
|
7
|
+
const { addContactHours } = require('../../utils/scheduleUtils');
|
|
5
8
|
|
|
6
9
|
const { SafetyNetPledge } = require('../../models/safetyNetPledgeModel');
|
|
7
10
|
|
|
@@ -13,24 +16,36 @@ const SWEEP_LIMIT = 200;
|
|
|
13
16
|
const BREACH_CATEGORY = 'contact_team_member';
|
|
14
17
|
const BREACH_QUESTION = 'El paciente sigue esperando el contacto que se le prometió';
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
const DELIVERY_GRACE_MS = 10 * 60 * 1000;
|
|
20
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
async function repliedSince(filter) {
|
|
23
|
+
const [reply] = await getMessages(filter, { sort: { createdAt: -1 }, limit: 1 });
|
|
18
24
|
return Boolean(reply);
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
const humanRepliedSince = (code, since) => repliedSince(humanReplyFilter(code, since));
|
|
28
|
+
const humanReplySettledSince = (code, since) => repliedSince(
|
|
29
|
+
settledHumanReplyFilter(code, since, new Date(Date.now() - DELIVERY_GRACE_MS))
|
|
30
|
+
);
|
|
31
|
+
const anyoneRepliedSince = (code, since) => repliedSince(anyReplyFilter(code, since));
|
|
32
|
+
|
|
21
33
|
function pledgeDueAt(pledgedAt) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
const configured = addContactHours(pledgedAt, getPledgeMs() / HOUR_MS, CONTACT_WINDOW);
|
|
35
|
+
if (configured) return { dueAt: configured };
|
|
36
|
+
|
|
37
|
+
logger.error('[SafetyNetPledge] The configured pledge does not fit in working hours; using the default', {
|
|
38
|
+
pledgeMs: getPledgeMs()
|
|
39
|
+
});
|
|
40
|
+
return { dueAt: addContactHours(pledgedAt, DEFAULT_PLEDGE_MS / HOUR_MS, CONTACT_WINDOW) };
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
async function openPledge({ code }) {
|
|
29
44
|
const pledgedAt = new Date();
|
|
30
|
-
const { dueAt
|
|
31
|
-
const pledge = await SafetyNetPledge.create({ code, pledgedAt, dueAt
|
|
32
|
-
logger.info('[SafetyNetPledge] Contact pledged', { code, dueAt
|
|
33
|
-
return { pledgeId: pledge._id, dueAt
|
|
45
|
+
const { dueAt } = pledgeDueAt(pledgedAt);
|
|
46
|
+
const pledge = await SafetyNetPledge.create({ code, pledgedAt, dueAt });
|
|
47
|
+
logger.info('[SafetyNetPledge] Contact pledged', { code, dueAt });
|
|
48
|
+
return { pledgeId: pledge._id, dueAt };
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
async function reportBreach(pledge) {
|
|
@@ -51,6 +66,11 @@ const settle = async (id, from, state) => Boolean(
|
|
|
51
66
|
await SafetyNetPledge.findOneAndUpdate({ _id: id, state: from }, { $set: { state, resolvedAt: new Date() } }).lean()
|
|
52
67
|
);
|
|
53
68
|
|
|
69
|
+
const returnToPending = (id) => SafetyNetPledge.updateOne(
|
|
70
|
+
{ _id: id, state: 'reporting' },
|
|
71
|
+
{ $set: { state: 'pending', reportingSince: null } }
|
|
72
|
+
);
|
|
73
|
+
|
|
54
74
|
const takeReportingLease = async (id) => Boolean(
|
|
55
75
|
await SafetyNetPledge.findOneAndUpdate(
|
|
56
76
|
{ _id: id, $or: [{ state: 'pending' }, { state: 'reporting', reportingSince: { $lt: new Date(Date.now() - REPORT_LEASE_MS) } }] },
|
|
@@ -73,13 +93,18 @@ async function sweepSafetyNetPledges() {
|
|
|
73
93
|
try {
|
|
74
94
|
if (!await takeReportingLease(pledge._id)) continue;
|
|
75
95
|
|
|
76
|
-
if (await
|
|
96
|
+
if (await humanReplySettledSince(pledge.code, pledge.pledgedAt)) {
|
|
77
97
|
await settle(pledge._id, 'reporting', 'met');
|
|
78
98
|
continue;
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
if (await humanRepliedSince(pledge.code, pledge.pledgedAt)) {
|
|
102
|
+
await returnToPending(pledge._id);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
if (!await reportBreach(pledge)) {
|
|
82
|
-
await
|
|
107
|
+
await returnToPending(pledge._id);
|
|
83
108
|
logger.error('[SafetyNetPledge] Breach was not recorded, pledge returns to pending for the next sweep', { code: pledge.code });
|
|
84
109
|
continue;
|
|
85
110
|
}
|
|
@@ -104,7 +129,9 @@ const stopSafetyNetSweep = () => sweeper.stop();
|
|
|
104
129
|
module.exports = {
|
|
105
130
|
startSafetyNetSweep,
|
|
106
131
|
stopSafetyNetSweep,
|
|
132
|
+
anyoneRepliedSince,
|
|
107
133
|
humanRepliedSince,
|
|
134
|
+
humanReplySettledSince,
|
|
108
135
|
openPledge,
|
|
109
136
|
pledgeDueAt,
|
|
110
137
|
sweepSafetyNetPledges,
|
|
@@ -31,7 +31,7 @@ const { releaseHeldMessages, sweepHeldMessages } = require('../services/heldMess
|
|
|
31
31
|
const { BatchingManager } = require('../core/BatchingManager');
|
|
32
32
|
const { ProcessingPipeline } = require('../core/ProcessingPipeline');
|
|
33
33
|
const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KIND } = require('../clinical');
|
|
34
|
-
const { coverUnansweredPatient } = require('../clinical/services/pausedSafetyNetService');
|
|
34
|
+
const { UNASSIGNED, coverUnansweredPatient } = require('../clinical/services/pausedSafetyNetService');
|
|
35
35
|
const { buildOutreachCarrier } = require('../clinical/services/outreachCarrierService');
|
|
36
36
|
const { routineOutreachCollisionReason } = require('../clinical/services/outreachSweepService');
|
|
37
37
|
const {
|
|
@@ -693,7 +693,7 @@ class NexusMessaging {
|
|
|
693
693
|
return null;
|
|
694
694
|
});
|
|
695
695
|
if (!resolved) {
|
|
696
|
-
await coverUnansweredPatient({ code: chatId });
|
|
696
|
+
await coverUnansweredPatient({ code: chatId, reason: UNASSIGNED });
|
|
697
697
|
return null;
|
|
698
698
|
}
|
|
699
699
|
|
|
@@ -6,7 +6,6 @@ const safetyNetPledgeSchema = new mongoose.Schema({
|
|
|
6
6
|
code: { type: String, required: true },
|
|
7
7
|
pledgedAt: { type: Date, required: true },
|
|
8
8
|
dueAt: { type: Date, required: true },
|
|
9
|
-
deferredToWindow: { type: Boolean, default: false },
|
|
10
9
|
state: { type: String, enum: PLEDGE_STATES, default: 'pending' },
|
|
11
10
|
reportingSince: { type: Date, default: null },
|
|
12
11
|
resolvedAt: { type: Date, default: null },
|
|
@@ -50,6 +50,29 @@ function contactHoursBetween(from, to, { timeZone, startHour, endHour }) {
|
|
|
50
50
|
return cursor.isBefore(end) ? Infinity : total;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function addContactHours(from, hours, { timeZone, startHour, endHour }) {
|
|
54
|
+
const start = moment.tz(from, timeZone);
|
|
55
|
+
if (!start.isValid() || !(hours >= 0)) {
|
|
56
|
+
logger.warn('[addContactHours] Invalid input', { from, hours, timeZone });
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let remaining = hours * 60 * 60 * 1000;
|
|
61
|
+
let cursor = start.clone();
|
|
62
|
+
for (let day = 0; day < MAX_CONTACT_SPAN_DAYS; day += 1) {
|
|
63
|
+
const opens = cursor.clone().startOf('day').hour(startHour);
|
|
64
|
+
const closes = cursor.clone().startOf('day').hour(endHour);
|
|
65
|
+
if (cursor.isBefore(opens)) cursor = opens;
|
|
66
|
+
if (cursor.isBefore(closes)) {
|
|
67
|
+
const available = closes.diff(cursor);
|
|
68
|
+
if (remaining < available) return cursor.clone().add(remaining, 'ms').toDate();
|
|
69
|
+
remaining -= available;
|
|
70
|
+
}
|
|
71
|
+
cursor = opens.clone().add(1, 'day');
|
|
72
|
+
}
|
|
73
|
+
return remaining === 0 ? cursor.toDate() : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
function calculateDelay(sendTime, timeZone) {
|
|
54
77
|
if (!sendTime) return DEFAULT_DELAY;
|
|
55
78
|
|
|
@@ -88,4 +111,4 @@ function calculateDelay(sendTime, timeZone) {
|
|
|
88
111
|
}
|
|
89
112
|
}
|
|
90
113
|
|
|
91
|
-
module.exports = { calculateDelay, clampToHourWindow, isWithinHourWindow, contactHoursBetween, DEFAULT_TIMEZONE };
|
|
114
|
+
module.exports = { addContactHours, calculateDelay, clampToHourWindow, isWithinHourWindow, contactHoursBetween, DEFAULT_TIMEZONE };
|