@peopl-health/nexus 5.67.0-dev.11889 → 5.67.0-dev.11929
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/services/nutritionPlanService.js +18 -8
- package/lib/clinical/services/promptComposerService.js +12 -3
- package/lib/controllers/escalationReviewController.js +20 -2
- package/lib/eval/EvalProvider.js +2 -1
- package/lib/models/escalationReviewModel.js +10 -0
- package/lib/routes/index.js +2 -0
- package/lib/services/escalationReviewService.js +33 -1
- package/lib/services/reviewBoardService.js +9 -10
- package/lib/services/reviewDeadlineService.js +82 -0
- package/lib/utils/dateUtils.js +22 -0
- package/package.json +1 -1
|
@@ -33,30 +33,40 @@ function readPlanSection(section) {
|
|
|
33
33
|
|
|
34
34
|
let lastGuide = '';
|
|
35
35
|
let guideRead = null;
|
|
36
|
+
let readSeq = 0;
|
|
37
|
+
let appliedSeq = 0;
|
|
36
38
|
|
|
37
|
-
// One shared read; a failure keeps the last guide that was read successfully.
|
|
38
39
|
function readGuide() {
|
|
39
40
|
if (guideRead) return guideRead;
|
|
40
|
-
|
|
41
|
+
const seq = ++readSeq;
|
|
42
|
+
const read = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
|
|
41
43
|
.then(({ resolvedPrompt }) => {
|
|
42
|
-
|
|
44
|
+
// A read that hung past a newer one must not overwrite what the newer one found.
|
|
45
|
+
if (seq > appliedSeq) {
|
|
46
|
+
appliedSeq = seq;
|
|
47
|
+
lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
|
|
48
|
+
}
|
|
43
49
|
return lastGuide;
|
|
44
50
|
})
|
|
45
51
|
.catch((error) => {
|
|
46
52
|
logger.warn('[nutritionPlanService] nutrition guide unreadable; using the last one read', { error: error.message });
|
|
47
53
|
return lastGuide;
|
|
48
54
|
})
|
|
49
|
-
.finally(() => { guideRead = null; });
|
|
50
|
-
|
|
55
|
+
.finally(() => { if (guideRead === read) guideRead = null; });
|
|
56
|
+
guideRead = read;
|
|
57
|
+
return read;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
// The guide only adds to the plan, so a slow read must not hold the patient's turn
|
|
60
|
+
// The guide only adds to the plan, so a slow read must not hold the patient's turn,
|
|
61
|
+
// and a read that never settles must not hold every later turn either.
|
|
54
62
|
async function loadNutritionGuide() {
|
|
55
63
|
let timer;
|
|
64
|
+
const read = readGuide();
|
|
56
65
|
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(null), GUIDE_TIMEOUT_MS); });
|
|
57
|
-
const guide = await Promise.race([
|
|
66
|
+
const guide = await Promise.race([read, timedOut]);
|
|
58
67
|
clearTimeout(timer);
|
|
59
68
|
if (guide !== null) return guide;
|
|
69
|
+
if (guideRead === read) guideRead = null;
|
|
60
70
|
logger.warn('[nutritionPlanService] nutrition guide read timed out; using the last one read', { timeoutMs: GUIDE_TIMEOUT_MS });
|
|
61
71
|
return lastGuide;
|
|
62
72
|
}
|
|
@@ -78,5 +88,5 @@ module.exports = {
|
|
|
78
88
|
NUTRITION_GUIDE_PROMPT_ID,
|
|
79
89
|
GUIDE_TIMEOUT_MS,
|
|
80
90
|
resolveNutritionPlan,
|
|
81
|
-
_test: { reset: () => { lastGuide = ''; guideRead = null; } },
|
|
91
|
+
_test: { reset: () => { lastGuide = ''; guideRead = null; readSeq = 0; appliedSeq = 0; } },
|
|
82
92
|
};
|
|
@@ -14,6 +14,8 @@ const promptCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
|
14
14
|
const snippetCache = new MapCache({ maxSize: 200, ttl: CACHE_TTL });
|
|
15
15
|
const toolCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
16
16
|
const presetCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
17
|
+
let promptReadClock = 0;
|
|
18
|
+
let promptCacheEpoch = 0;
|
|
17
19
|
|
|
18
20
|
async function fetchConfigRecords(table, filter) {
|
|
19
21
|
const records = await getRecordByFilter(Config_ID, table, filter);
|
|
@@ -47,11 +49,17 @@ async function fetchPreset(presetId, version = null) {
|
|
|
47
49
|
async function fetchBasePrompt(promptId) {
|
|
48
50
|
const cacheKey = `prompt:${promptId}`;
|
|
49
51
|
const cached = promptCache.get(cacheKey);
|
|
50
|
-
if (cached) return cached;
|
|
52
|
+
if (cached) return cached.record;
|
|
51
53
|
|
|
54
|
+
// A result may fill the cache unless a read started later already did, or the cache was cleared meanwhile.
|
|
55
|
+
const startedAt = ++promptReadClock;
|
|
56
|
+
const epoch = promptCacheEpoch;
|
|
52
57
|
const records = await getRecordByFilter(Config_ID, 'responses', `{prompt_id} = "${promptId}"`);
|
|
53
58
|
const record = records?.[0] || null;
|
|
54
|
-
|
|
59
|
+
const current = promptCache.get(cacheKey);
|
|
60
|
+
if (record && epoch === promptCacheEpoch && !(current && current.startedAt > startedAt)) {
|
|
61
|
+
promptCache.set(cacheKey, { record, startedAt });
|
|
62
|
+
}
|
|
55
63
|
return record;
|
|
56
64
|
}
|
|
57
65
|
|
|
@@ -257,9 +265,10 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
|
|
|
257
265
|
|
|
258
266
|
function clearCache() {
|
|
259
267
|
promptCache.clear();
|
|
268
|
+
promptCacheEpoch += 1;
|
|
260
269
|
snippetCache.clear();
|
|
261
270
|
toolCache.clear();
|
|
262
271
|
presetCache.clear();
|
|
263
272
|
}
|
|
264
273
|
|
|
265
|
-
module.exports = { composePrompt, resolveTools, clearCache, _test: { applyVariables, sortSnippets, resetUnlinkedWarnings: () => warnedUnlinked.clear() } };
|
|
274
|
+
module.exports = { composePrompt, resolveTools, clearCache, variablesReferencedIn, _test: { applyVariables, sortSnippets, resetUnlinkedWarnings: () => warnedUnlinked.clear() } };
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
handOffToDoctor,
|
|
12
12
|
listEscalationReviews,
|
|
13
13
|
openEscalationReview,
|
|
14
|
+
reclassifyUrgency,
|
|
14
15
|
recordDoctorDecision,
|
|
15
16
|
requestBotDraft,
|
|
16
17
|
sendApprovedIndications,
|
|
@@ -49,8 +50,8 @@ const openEscalationReviewController = async (req, res) => {
|
|
|
49
50
|
|
|
50
51
|
const getEscalationReviewController = async (req, res) => {
|
|
51
52
|
try {
|
|
52
|
-
const { review, isApprover } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
53
|
-
res.status(200).json({ success: true, review, isApprover });
|
|
53
|
+
const { review, isApprover, responseDeadline } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
54
|
+
res.status(200).json({ success: true, review, isApprover, responseDeadline });
|
|
54
55
|
} catch (error) {
|
|
55
56
|
respondWithError(res, error, 'reading escalation review', { reviewId: req.params?.id });
|
|
56
57
|
}
|
|
@@ -223,6 +224,22 @@ const closeEscalationReviewController = async (req, res) => {
|
|
|
223
224
|
}
|
|
224
225
|
};
|
|
225
226
|
|
|
227
|
+
const reclassifyEscalationReviewUrgencyController = async (req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const { urgencyLevel, reason, triggeredBy } = req.body || {};
|
|
230
|
+
const { review, responseDeadline } = await reclassifyUrgency({
|
|
231
|
+
id: req.params.id,
|
|
232
|
+
urgencyLevel,
|
|
233
|
+
reason,
|
|
234
|
+
reclassifiedBy: triggeredBy,
|
|
235
|
+
requestId: req.requestId,
|
|
236
|
+
});
|
|
237
|
+
res.status(200).json({ success: true, review, responseDeadline });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
respondWithError(res, error, 'reclassifying escalation review urgency', { reviewId: req.params?.id });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
226
243
|
const cancelEscalationReviewController = async (req, res) => {
|
|
227
244
|
try {
|
|
228
245
|
const { triggeredBy, reason = null } = req.body || {};
|
|
@@ -265,6 +282,7 @@ module.exports = {
|
|
|
265
282
|
listEscalationReviewsController,
|
|
266
283
|
listReviewBoardController,
|
|
267
284
|
openEscalationReviewController,
|
|
285
|
+
reclassifyEscalationReviewUrgencyController,
|
|
268
286
|
recordEscalationReviewDecisionController,
|
|
269
287
|
sendEscalationReviewIndicationsController,
|
|
270
288
|
setEscalationReviewCasesController,
|
package/lib/eval/EvalProvider.js
CHANGED
|
@@ -7,6 +7,7 @@ const { PhiProcessor } = require('../core/PhiProcessor');
|
|
|
7
7
|
const { buildDateAnchor } = require('../clinical/helpers/dateAnchorHelper');
|
|
8
8
|
const { DefaultMemoryManager, EnhancedMemoryManager, createLLMProvider, composePrompt, resolveTools, getAssistantById } = require('../clinical');
|
|
9
9
|
const { resolveClinicalContext } = require('../clinical/services/clinicalContextReadService');
|
|
10
|
+
const { variablesReferencedIn } = require('../clinical/services/promptComposerService');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Promptfoo-compatible eval provider that wraps the real Nexus pipeline.
|
|
@@ -159,7 +160,7 @@ class EvalProvider {
|
|
|
159
160
|
resolvedPresetId = composedPresetId || resolvedPresetId;
|
|
160
161
|
} else {
|
|
161
162
|
devContent = fallbackPrompt;
|
|
162
|
-
interpolatedVariables =
|
|
163
|
+
interpolatedVariables = variablesReferencedIn(devContent);
|
|
163
164
|
devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -145,6 +145,14 @@ const interruptionResolutionSchema = new mongoose.Schema({
|
|
|
145
145
|
notes: { type: String, required: true },
|
|
146
146
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
147
147
|
|
|
148
|
+
const urgencyReclassificationSchema = new mongoose.Schema({
|
|
149
|
+
fromLevel: { type: String, enum: URGENCY_LEVELS, required: true },
|
|
150
|
+
toLevel: { type: String, enum: URGENCY_LEVELS, required: true },
|
|
151
|
+
reason: { type: String, required: true },
|
|
152
|
+
reclassifiedBy: { type: String, required: true },
|
|
153
|
+
at: { type: Date, required: true },
|
|
154
|
+
}, { _id: false });
|
|
155
|
+
|
|
148
156
|
const identityBlockSchema = new mongoose.Schema({
|
|
149
157
|
missing: { type: [String], default: [] },
|
|
150
158
|
blockedAt: { type: Date, required: true },
|
|
@@ -179,6 +187,7 @@ const escalationReviewSchema = new mongoose.Schema({
|
|
|
179
187
|
openedBy: { type: String, required: true },
|
|
180
188
|
assistantPausedByReview: { type: Boolean, default: false },
|
|
181
189
|
alarms: { type: [alarmSchema], default: [] },
|
|
190
|
+
urgencyReclassifications: { type: [urgencyReclassificationSchema], default: [] },
|
|
182
191
|
updates: { type: [reviewUpdateSchema], default: [] },
|
|
183
192
|
updatedSinceSeen: { type: Boolean, default: false },
|
|
184
193
|
followUpCases: { type: [String], default: undefined },
|
|
@@ -228,6 +237,7 @@ module.exports = {
|
|
|
228
237
|
INTERRUPTED_STATUSES,
|
|
229
238
|
FOLLOWUP_STATUSES,
|
|
230
239
|
RETURN_REASONS,
|
|
240
|
+
URGENCY_LEVELS,
|
|
231
241
|
URGENCY_RANK,
|
|
232
242
|
getEscalationReview,
|
|
233
243
|
escalationReviewSchema,
|
package/lib/routes/index.js
CHANGED
|
@@ -136,6 +136,7 @@ const escalationReviewRouteDefinitions = {
|
|
|
136
136
|
'POST /:id/patient-deliveries': 'sendEscalationReviewIndicationsController',
|
|
137
137
|
'POST /:id/close': 'closeEscalationReviewController',
|
|
138
138
|
'POST /:id/cancel': 'cancelEscalationReviewController',
|
|
139
|
+
'POST /:id/urgency-reclassifications': 'reclassifyEscalationReviewUrgencyController',
|
|
139
140
|
'POST /:id/case-sync': 'syncEscalationReviewCaseController'
|
|
140
141
|
};
|
|
141
142
|
|
|
@@ -312,6 +313,7 @@ const builtInControllers = {
|
|
|
312
313
|
sendEscalationReviewIndicationsController: escalationReviewController.sendEscalationReviewIndicationsController,
|
|
313
314
|
closeEscalationReviewController: escalationReviewController.closeEscalationReviewController,
|
|
314
315
|
cancelEscalationReviewController: escalationReviewController.cancelEscalationReviewController,
|
|
316
|
+
reclassifyEscalationReviewUrgencyController: escalationReviewController.reclassifyEscalationReviewUrgencyController,
|
|
315
317
|
syncEscalationReviewCaseController: escalationReviewController.syncEscalationReviewCaseController,
|
|
316
318
|
|
|
317
319
|
// Office assistant controllers
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
INTERRUPTED_STATUSES,
|
|
17
17
|
FOLLOWUP_STATUSES,
|
|
18
18
|
RETURN_REASONS,
|
|
19
|
+
URGENCY_LEVELS,
|
|
19
20
|
URGENCY_RANK,
|
|
20
21
|
getEscalationReview,
|
|
21
22
|
} = require('../models/escalationReviewModel');
|
|
@@ -36,6 +37,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
|
|
|
36
37
|
const { MAX_NOTE_LENGTH, getPatientTasks, openCaseIds, updatePatientTask } = require('./patientTaskService');
|
|
37
38
|
const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
|
|
38
39
|
const { switchThreadStoppedStatus } = require('./threadService');
|
|
40
|
+
const { effectiveUrgencyOf, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
|
|
39
41
|
|
|
40
42
|
const AUDIT_SOURCE = 'escalation_review';
|
|
41
43
|
const RECENT_REVIEW_LIMIT = 20;
|
|
@@ -63,6 +65,9 @@ const ALERT_MARKS = { stat: '🔴', asap: '🟠' };
|
|
|
63
65
|
const DEFAULT_STAT_REALERT_MINUTES = 15;
|
|
64
66
|
const PRE_DECISION_STATUSES = ['open', 'documenting', 'draft_ready', 'handing_off', 'awaiting_doctor'];
|
|
65
67
|
const ALERT_OPERATOR = 'bot:escalationAlert';
|
|
68
|
+
const RECLASSIFIABLE_STATUSES = [...DOCUMENTING_STATUSES, 'awaiting_doctor'];
|
|
69
|
+
const SAME_URGENCY = 'The review is already at that urgency';
|
|
70
|
+
const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
|
|
66
71
|
const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
|
|
67
72
|
const APPROVALS = ['approved', 'edited_approved'];
|
|
68
73
|
const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
|
|
@@ -687,7 +692,7 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
|
687
692
|
.updateOne({ _id: review._id }, { $max: { approverViewedAt: readAt } }, { timestamps: false })
|
|
688
693
|
.catch(warnUnmarked);
|
|
689
694
|
}
|
|
690
|
-
return { review, isApprover };
|
|
695
|
+
return { review, isApprover, responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()) };
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
async function getReviewTimeline({ id } = {}) {
|
|
@@ -1745,6 +1750,32 @@ async function closeEscalationReview({
|
|
|
1745
1750
|
return syncCase(await resumeAssistant(review, operator, requestId), operator, requestId);
|
|
1746
1751
|
}
|
|
1747
1752
|
|
|
1753
|
+
async function reclassifyUrgency({ id, urgencyLevel, reason, reclassifiedBy, requestId = null } = {}) {
|
|
1754
|
+
const reviewId = reviewIdOf(id);
|
|
1755
|
+
const operator = requiredText(reclassifiedBy, 'reclassifiedBy');
|
|
1756
|
+
if (!URGENCY_LEVELS.includes(urgencyLevel)) throw httpError(400, `urgencyLevel must be one of: ${URGENCY_LEVELS.join(', ')}`);
|
|
1757
|
+
const why = requiredText(reason, 'reason');
|
|
1758
|
+
|
|
1759
|
+
const current = await loadReview(reviewId);
|
|
1760
|
+
const fromLevel = effectiveUrgencyOf(current)?.urgency;
|
|
1761
|
+
if (!fromLevel) throw httpError(409, STATUS_CONFLICT);
|
|
1762
|
+
if (fromLevel === urgencyLevel) throw httpError(409, SAME_URGENCY);
|
|
1763
|
+
|
|
1764
|
+
const entry = { fromLevel, toLevel: urgencyLevel, reason: why, reclassifiedBy: operator, at: new Date() };
|
|
1765
|
+
let review;
|
|
1766
|
+
try {
|
|
1767
|
+
review = await updateReview(reviewId, RECLASSIFIABLE_STATUSES, { $push: { urgencyReclassifications: entry } }, {
|
|
1768
|
+
alarms: { $size: (current.alarms ?? []).length },
|
|
1769
|
+
urgencyReclassifications: { $size: (current.urgencyReclassifications ?? []).length },
|
|
1770
|
+
});
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
if (error?.statusCode === 409 && RECLASSIFIABLE_STATUSES.includes(current.status)) throw httpError(409, RECLASSIFY_CHANGED);
|
|
1773
|
+
throw error;
|
|
1774
|
+
}
|
|
1775
|
+
await audit(review, operator, requestId, { action: 'urgency_reclassified', fromLevel, toLevel: urgencyLevel, reason: why });
|
|
1776
|
+
return { review, responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()) };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1748
1779
|
async function cancelEscalationReview({ id, cancelledBy, reason = null, requestId = null } = {}) {
|
|
1749
1780
|
const reviewId = reviewIdOf(id);
|
|
1750
1781
|
const operator = requiredText(cancelledBy, 'cancelledBy');
|
|
@@ -1794,6 +1825,7 @@ module.exports = {
|
|
|
1794
1825
|
handOffToDoctor,
|
|
1795
1826
|
listEscalationReviews,
|
|
1796
1827
|
openEscalationReview,
|
|
1828
|
+
reclassifyUrgency,
|
|
1797
1829
|
recordEscalationAlarm,
|
|
1798
1830
|
recordDirectIndication,
|
|
1799
1831
|
recordDoctorDecision,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { httpError } = require('../utils/httpErrorUtils');
|
|
2
2
|
const { FOLLOWUP_STATUSES, URGENCY_RANK, getEscalationReview } = require('../models/escalationReviewModel');
|
|
3
3
|
|
|
4
|
+
const { effectiveUrgencyOf, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
|
|
5
|
+
|
|
4
6
|
const DONE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
7
|
const DONE_STATUSES = ['closed', 'cancelled'];
|
|
6
8
|
const BOARD_BUCKETS = ['pending', 'followup', 'done'];
|
|
@@ -19,15 +21,9 @@ const bucketQuery = (bucket, now) => {
|
|
|
19
21
|
}
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const leadingAlarm = (alarms) => alarms.reduce((lead, alarm) => (
|
|
25
|
-
!lead || rankOf(alarm) > rankOf(lead) || (rankOf(alarm) === rankOf(lead) && alarm.raisedAt > lead.raisedAt) ? alarm : lead
|
|
26
|
-
), null);
|
|
27
|
-
|
|
28
|
-
const boardRowOf = (review, bucket) => {
|
|
24
|
+
const boardRowOf = (review, bucket, deadlinePolicy) => {
|
|
29
25
|
const alarms = review.alarms || [];
|
|
30
|
-
const lead =
|
|
26
|
+
const lead = effectiveUrgencyOf(review);
|
|
31
27
|
const raised = alarms.map((alarm) => alarm.raisedAt).filter(Boolean);
|
|
32
28
|
const since = raised.length ? new Date(Math.min(...raised.map(Number))) : review.createdAt;
|
|
33
29
|
return {
|
|
@@ -35,12 +31,13 @@ const boardRowOf = (review, bucket) => {
|
|
|
35
31
|
patientCode: review.patientCode,
|
|
36
32
|
bucket,
|
|
37
33
|
status: review.status,
|
|
38
|
-
urgency: lead?.
|
|
34
|
+
urgency: lead?.urgency || null,
|
|
39
35
|
symptom: lead?.symptom || null,
|
|
40
36
|
alarmCount: alarms.length,
|
|
41
37
|
since,
|
|
42
38
|
closedAt: review.closedAt || null,
|
|
43
39
|
updatedSinceSeen: review.updatedSinceSeen === true,
|
|
40
|
+
responseDeadline: responseDeadlineOf(review, deadlinePolicy),
|
|
44
41
|
};
|
|
45
42
|
};
|
|
46
43
|
|
|
@@ -53,9 +50,11 @@ async function listReviewBoard({ bucket, now = new Date() } = {}) {
|
|
|
53
50
|
const reviews = await getEscalationReview()
|
|
54
51
|
.find(bucketQuery(bucket, now), {
|
|
55
52
|
patientCode: 1, status: 1, 'alarms.urgencyLevel': 1, 'alarms.symptom': 1, 'alarms.raisedAt': 1, createdAt: 1, closedAt: 1, updatedSinceSeen: 1,
|
|
53
|
+
'decisions.createdAt': 1, urgencyReclassifications: 1,
|
|
56
54
|
})
|
|
57
55
|
.lean();
|
|
58
|
-
const
|
|
56
|
+
const deadlinePolicy = responseDeadlinePolicy();
|
|
57
|
+
const rows = reviews.map((review) => boardRowOf(review, bucket, deadlinePolicy))
|
|
59
58
|
.sort(bucket === 'done' ? byMostRecentlyClosed : byUrgencyThenAge);
|
|
60
59
|
const seen = new Set();
|
|
61
60
|
return rows.filter((row) => !seen.has(row.patientCode) && seen.add(row.patientCode));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
2
|
+
const { addWorkingMinutes } = require('../utils/dateUtils');
|
|
3
|
+
const { FOLLOWUP_STATUSES, URGENCY_RANK } = require('../models/escalationReviewModel');
|
|
4
|
+
|
|
5
|
+
const RESPONSE_MINUTES = { stat: 15, asap: 60, urgent: 240, routine: 720 };
|
|
6
|
+
const MAX_RESPONSE_MINUTES = 7 * 24 * 60;
|
|
7
|
+
const BUSINESS_HOURS_LEVELS = ['urgent', 'routine'];
|
|
8
|
+
const TEAM_TIMEZONE = 'America/Mexico_City';
|
|
9
|
+
const DEFAULT_TEAM_HOURS = { days: [1, 2, 3, 4, 5], startHour: 8, endHour: 20 };
|
|
10
|
+
|
|
11
|
+
const rankOf = (level) => URGENCY_RANK[level] || 0;
|
|
12
|
+
const timeOf = (value) => (value ? new Date(value).getTime() : NaN);
|
|
13
|
+
|
|
14
|
+
const configuredMinutes = (level) => {
|
|
15
|
+
const minutes = Number(runtimeConfig.get(`ESCALATION_REVIEW_RESPONSE_MINUTES_${level.toUpperCase()}`, RESPONSE_MINUTES[level]));
|
|
16
|
+
return Number.isFinite(minutes) && minutes > 0 && minutes <= MAX_RESPONSE_MINUTES ? minutes : RESPONSE_MINUTES[level];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function configuredTeamHours() {
|
|
20
|
+
const entries = String(runtimeConfig.get('ESCALATION_REVIEW_TEAM_DAYS', '')).split(',').map((day) => day.trim()).filter(Boolean);
|
|
21
|
+
const days = entries.map(Number);
|
|
22
|
+
const isValidDays = days.length > 0 && days.every((day) => Number.isInteger(day) && day >= 1 && day <= 7);
|
|
23
|
+
const startHour = Number(runtimeConfig.get('ESCALATION_REVIEW_TEAM_START_HOUR', DEFAULT_TEAM_HOURS.startHour));
|
|
24
|
+
const endHour = Number(runtimeConfig.get('ESCALATION_REVIEW_TEAM_END_HOUR', DEFAULT_TEAM_HOURS.endHour));
|
|
25
|
+
const isValidSpan = Number.isInteger(startHour) && Number.isInteger(endHour) && startHour >= 0 && startHour < endHour && endHour <= 24;
|
|
26
|
+
return {
|
|
27
|
+
timeZone: TEAM_TIMEZONE,
|
|
28
|
+
days: isValidDays ? [...new Set(days)] : DEFAULT_TEAM_HOURS.days,
|
|
29
|
+
startHour: isValidSpan ? startHour : DEFAULT_TEAM_HOURS.startHour,
|
|
30
|
+
endHour: isValidSpan ? endHour : DEFAULT_TEAM_HOURS.endHour,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function responseDeadlinePolicy() {
|
|
35
|
+
return {
|
|
36
|
+
responseMinutes: Object.fromEntries(Object.keys(RESPONSE_MINUTES).map((level) => [level, configuredMinutes(level)])),
|
|
37
|
+
teamHours: configuredTeamHours(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function alarmLevelsOf(review) {
|
|
42
|
+
const reclassifications = [...(review.urgencyReclassifications ?? [])].sort((a, b) => timeOf(a.at) - timeOf(b.at));
|
|
43
|
+
return (review.alarms ?? []).map((alarm) => reclassifications
|
|
44
|
+
.filter((change) => timeOf(alarm.raisedAt) <= timeOf(change.at))
|
|
45
|
+
.reduce((entry, change) => ({
|
|
46
|
+
alarm,
|
|
47
|
+
level: change.toLevel,
|
|
48
|
+
clockFrom: rankOf(change.toLevel) > rankOf(entry.level) ? change.at : entry.clockFrom,
|
|
49
|
+
}), { alarm, level: alarm.urgencyLevel, clockFrom: alarm.raisedAt }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function effectiveUrgencyOf(review) {
|
|
53
|
+
const lead = alarmLevelsOf(review).reduce((top, entry) => (
|
|
54
|
+
!top || rankOf(entry.level) > rankOf(top.level)
|
|
55
|
+
|| (rankOf(entry.level) === rankOf(top.level) && timeOf(entry.alarm.raisedAt) > timeOf(top.alarm.raisedAt)) ? entry : top
|
|
56
|
+
), null);
|
|
57
|
+
return lead ? { urgency: lead.level, symptom: lead.alarm.symptom ?? null } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dueAtFor(from, level, policy) {
|
|
61
|
+
const minutes = policy.responseMinutes[level];
|
|
62
|
+
if (BUSINESS_HOURS_LEVELS.includes(level)) return addWorkingMinutes(new Date(from), minutes, policy.teamHours);
|
|
63
|
+
return new Date(timeOf(from) + minutes * 60 * 1000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function responseDeadlineOf(review, policy) {
|
|
67
|
+
if (review.closedAt || FOLLOWUP_STATUSES.includes(review.status)) return null;
|
|
68
|
+
const decidedAt = timeOf((review.decisions ?? []).at(-1)?.createdAt);
|
|
69
|
+
const waiting = alarmLevelsOf(review)
|
|
70
|
+
.filter(({ alarm }) => Number.isNaN(decidedAt) || timeOf(alarm.raisedAt) > decidedAt)
|
|
71
|
+
.map(({ level, clockFrom }) => ({ urgency: level, dueAt: dueAtFor(clockFrom, level, policy) }))
|
|
72
|
+
.filter(({ dueAt }) => dueAt);
|
|
73
|
+
if (!waiting.length) return null;
|
|
74
|
+
const earliest = waiting.reduce((first, entry) => (entry.dueAt < first.dueAt ? entry : first));
|
|
75
|
+
return { ...earliest, isBusinessHoursOnly: BUSINESS_HOURS_LEVELS.includes(earliest.urgency) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
effectiveUrgencyOf,
|
|
80
|
+
responseDeadlineOf,
|
|
81
|
+
responseDeadlinePolicy,
|
|
82
|
+
};
|
package/lib/utils/dateUtils.js
CHANGED
|
@@ -58,7 +58,29 @@ const daysSince = (from, now = Date.now()) => {
|
|
|
58
58
|
return Number.isNaN(at) ? null : Math.max(0, Math.floor((now - at) / 86400000));
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
const MAX_WORKING_DAY_STEPS = 400;
|
|
62
|
+
|
|
63
|
+
const addWorkingMinutes = (start, minutes, { timeZone, days, startHour, endHour }) => {
|
|
64
|
+
let at = moment.tz(start, timeZone);
|
|
65
|
+
let remaining = minutes;
|
|
66
|
+
for (let step = 0; step < MAX_WORKING_DAY_STEPS; step += 1) {
|
|
67
|
+
const opens = at.clone().startOf('day').hour(startHour);
|
|
68
|
+
const closes = at.clone().startOf('day').hour(endHour);
|
|
69
|
+
if (!days.includes(at.isoWeekday()) || !at.isBefore(closes)) {
|
|
70
|
+
at = at.clone().add(1, 'day').startOf('day').hour(startHour);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (at.isBefore(opens)) at = opens;
|
|
74
|
+
const available = closes.diff(at, 'minutes', true);
|
|
75
|
+
if (remaining <= available) return at.add(remaining, 'minutes').toDate();
|
|
76
|
+
remaining -= available;
|
|
77
|
+
at = closes;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
61
82
|
module.exports = {
|
|
83
|
+
addWorkingMinutes,
|
|
62
84
|
getCurrentMexicoDateTime,
|
|
63
85
|
getMexicoDayLabels,
|
|
64
86
|
parseDate,
|