@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.
@@ -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
- guideRead = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
41
+ const seq = ++readSeq;
42
+ const read = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
41
43
  .then(({ resolvedPrompt }) => {
42
- lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
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
- return guideRead;
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([readGuide(), timedOut]);
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
- if (record) promptCache.set(cacheKey, record);
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,
@@ -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 = [...new Set([...(devContent || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))];
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,
@@ -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 rankOf = (alarm) => URGENCY_RANK[alarm?.urgencyLevel] || 0;
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 = leadingAlarm(alarms);
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?.urgencyLevel || null,
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 rows = reviews.map((review) => boardRowOf(review, bucket))
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
+ };
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.0-dev.11889",
3
+ "version": "5.67.0-dev.11929",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",