@peopl-health/nexus 5.11.0-dev.1128 → 5.11.0-dev.1130

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.
@@ -8,7 +8,7 @@ const { overlayCohortPreset } = require('./helpers/cohortHelper');
8
8
  const { forkShadowTurn } = require('./services/shadowService');
9
9
  const { diverge } = require('./services/divergenceService');
10
10
  const { getAssistantById } = require('./services/assistantResolver');
11
- const { maybeSendBridge, clearBridgeState } = require('./services/bridgeService');
11
+ const { maybeSendBridge } = require('./services/bridgeService');
12
12
  const { getBridgePresetId } = require('./flags/bridgeConfig');
13
13
  const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
14
14
 
@@ -45,11 +45,7 @@ class AssistantProcessor {
45
45
 
46
46
  if (thread.isInCohort && !runOptions.toolRuntimeContext && runOptions.runId && thread.code) {
47
47
  try {
48
- runOptions.toolRuntimeContext = new ToolRuntimeContext({
49
- patientCode: thread.code,
50
- turnId: runOptions.runId,
51
- shouldContinue: runOptions.shouldContinue,
52
- });
48
+ runOptions.toolRuntimeContext = new ToolRuntimeContext({ patientCode: thread.code, turnId: runOptions.runId });
53
49
  } catch (error) {
54
50
  logger.warn('[AssistantProcessor] turn will run untraced', { code: thread.code, error: error.message });
55
51
  }
@@ -87,6 +83,7 @@ class AssistantProcessor {
87
83
  outbound: output,
88
84
  completed: runResult?.completed,
89
85
  predictionTimeMs,
86
+ shouldContinue: runOptions.shouldContinue,
90
87
  });
91
88
  } catch (error) {
92
89
  logger.warn('[AssistantProcessor] trace not stored', {
@@ -139,7 +136,6 @@ class AssistantProcessor {
139
136
  if (!this.sendMessage) throw new Error('sendMessage function not configured');
140
137
  if (!result?.output) return null;
141
138
  await this.sendMessage({ code, body: result.output, processed: true, origin: 'assistant', tools_executed: result.tools_executed, prompt: result.prompt, preset: result.preset, response_id: result.response_id, turnId: result.turnId, traceId: result.traceId });
142
- clearBridgeState(code);
143
139
  return result.output;
144
140
  }
145
141
  }
@@ -8,7 +8,6 @@ class ToolRuntimeContext {
8
8
  repos = {},
9
9
  contextSource = null,
10
10
  shadow = false,
11
- shouldContinue = null,
12
11
  } = {}) {
13
12
  if (typeof patientCode !== 'string' || !patientCode) {
14
13
  throw new Error('ToolRuntimeContext requires a non-empty patientCode');
@@ -23,7 +22,6 @@ class ToolRuntimeContext {
23
22
  this.patientCode = patientCode;
24
23
  this.turnId = turnId;
25
24
  this.sessionId = sessionId;
26
- this.shouldContinue = shouldContinue;
27
25
  this.repos = repos;
28
26
  this.contextSource = contextSource;
29
27
  this.enabledSkills = [];
@@ -44,17 +42,13 @@ class ToolRuntimeContext {
44
42
  return toolData;
45
43
  }
46
44
 
47
- isLive() {
48
- return typeof this.shouldContinue !== 'function' || this.shouldContinue();
49
- }
50
-
51
- async store({ outbound = null, completed = false, predictionTimeMs = null } = {}) {
45
+ async store({ outbound = null, completed = false, predictionTimeMs = null, shouldContinue = null } = {}) {
52
46
  this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
53
47
  this.trace.setOutbound(outbound);
54
- const superseded = !this.isLive();
55
- this.trace.setStatus(superseded ? 'superseded' : (completed ? 'completed' : 'incomplete'));
48
+ this.trace.setStatus(completed ? 'completed' : 'incomplete');
56
49
  this.trace.setTimings({ predictionTimeMs });
57
- await this.trace.save();
50
+ const superseded = shouldContinue && !shouldContinue();
51
+ if (!superseded) await this.trace.save();
58
52
  }
59
53
 
60
54
  setSafetyArtifact(artifact) {
@@ -11,7 +11,7 @@ const { logger } = require('../../utils/logger');
11
11
 
12
12
  const runtimeConfig = require('../../config/runtimeConfig');
13
13
 
14
- const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS, isSuperseded } = require('./BaseLLMProvider');
14
+ const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = require('./BaseLLMProvider');
15
15
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
16
16
 
17
17
  const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
@@ -69,7 +69,7 @@ class AnthropicProvider extends BaseLLMProvider {
69
69
  async _invokeModel({
70
70
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
71
71
  modelConfig, assistant, phiProcessor = null, trace = null, onFirstIteration = null,
72
- resolvedPromptId = null, resolvedPresetId = null, shouldContinue = null
72
+ resolvedPromptId = null, resolvedPresetId = null
73
73
  }, options = {}) {
74
74
  const executeTools = options.toolExecutor || handleFunctionCalls;
75
75
  let totalRetries = 0;
@@ -170,7 +170,7 @@ class AnthropicProvider extends BaseLLMProvider {
170
170
  && (response.content || []).some(block => block.type === 'tool_use');
171
171
  try {
172
172
  const decision = onFirstIteration({ narrationText, hasToolCalls });
173
- if (decision?.dedupNote) bridgeNote = decision.dedupNote;
173
+ if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
174
174
  } catch (err) {
175
175
  logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
176
176
  }
@@ -187,10 +187,6 @@ class AnthropicProvider extends BaseLLMProvider {
187
187
  let deliveryBreak = false;
188
188
  const maxIterations = this.maxFunctionRounds + MAX_DELIVERY_NUDGES + LOOP_ITERATION_HEADROOM;
189
189
  for (let iteration = 1; iteration <= maxIterations; iteration++) {
190
- if (isSuperseded(shouldContinue)) {
191
- logger.info('[AnthropicProvider] turn superseded — aborting tool loop', { iteration });
192
- break;
193
- }
194
190
  const content = finalResponse.content || [];
195
191
  const toolUses = content.filter(block => block.type === 'tool_use');
196
192
  const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
@@ -255,9 +251,9 @@ class AnthropicProvider extends BaseLLMProvider {
255
251
  }
256
252
 
257
253
  // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
258
- // visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
254
+ // visible — but not on a delivery break, which also exits with pending calls and is not exhaustion.
259
255
  const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
260
- if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak && !isSuperseded(shouldContinue)) {
256
+ if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak) {
261
257
  logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
262
258
  trace?.setSignals?.({ toolBudgetExhausted: true });
263
259
  }
@@ -18,10 +18,6 @@ const TERMINAL_DELIVERY_TOOLS = new Set(['DeliverPatientMessage']);
18
18
  const DELIVERY_NUDGE = 'sistema: el turno no puede cerrar sin un mensaje para el paciente. Llama ahora a DeliverPatientMessage con message_text en español. No repitas herramientas clínicas ya ejecutadas en este turno.';
19
19
  const MAX_DELIVERY_NUDGES = 1;
20
20
 
21
- function isSuperseded(shouldContinue) {
22
- return typeof shouldContinue === 'function' && !shouldContinue();
23
- }
24
-
25
21
  class BaseLLMProvider {
26
22
  constructor(options = {}) {
27
23
  if (new.target === BaseLLMProvider) {
@@ -336,8 +332,8 @@ class BaseLLMProvider {
336
332
  return result;
337
333
  }
338
334
 
339
- if (isSuperseded(config.shouldContinue)) {
340
- logger.info('[runConversation] Superseded — returning without retry', { attempt, shadow: config.shadow });
335
+ if (config.shadow && !config.shouldContinue()) {
336
+ logger.info('[runConversation] Shadow superseded — returning without retry', { attempt });
341
337
  return result;
342
338
  }
343
339
 
@@ -348,8 +344,8 @@ class BaseLLMProvider {
348
344
  await new Promise(r => setTimeout(r, 500));
349
345
  } catch (error) {
350
346
  logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
351
- if (isSuperseded(config.shouldContinue)) {
352
- logger.info('[runConversation] Superseded — abandoning after error', { attempt, shadow: config.shadow });
347
+ if (config.shadow && !config.shouldContinue()) {
348
+ logger.info('[runConversation] Shadow superseded — abandoning after error', { attempt });
353
349
  return { status: 'cancelled', output_text: '', tools_executed: [], retries: 0 };
354
350
  }
355
351
  if (attempt === maxRetries) throw error;
@@ -532,5 +528,4 @@ module.exports = {
532
528
  DELIVERY_NUDGE,
533
529
  MAX_DELIVERY_NUDGES,
534
530
  TERMINAL_DELIVERY_TOOLS,
535
- isSuperseded,
536
531
  };
@@ -9,7 +9,7 @@ const { logger } = require('../../utils/logger');
9
9
 
10
10
  const runtimeConfig = require('../../config/runtimeConfig');
11
11
 
12
- const { BaseLLMProvider, isSuperseded } = require('./BaseLLMProvider');
12
+ const { BaseLLMProvider } = require('./BaseLLMProvider');
13
13
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
14
14
 
15
15
  const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
@@ -69,7 +69,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
69
69
  shadow = false, shouldContinue = null, onFirstIteration = null
70
70
  }, options = {}) {
71
71
  const executeTools = options.toolExecutor || handleFunctionCalls;
72
- const superseded = () => isSuperseded(shouldContinue);
72
+ const shadowSuperseded = () => shadow && !shouldContinue();
73
73
  let totalRetries = 0;
74
74
  const allToolsExecuted = [];
75
75
  const allToolCalls = [];
@@ -127,7 +127,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
127
127
  const hasToolCalls = Boolean(assistant) && (response.output || []).some(item => item.type === 'function_call');
128
128
  try {
129
129
  const decision = onFirstIteration({ narrationText, hasToolCalls });
130
- if (decision?.dedupNote) bridgeNote = decision.dedupNote;
130
+ if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
131
131
  } catch (err) {
132
132
  logger.warn('[OpenAIResponsesProvider] bridge callback failed', { error: err.message });
133
133
  }
@@ -140,8 +140,8 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
140
140
  apiCallConfig.tool_choice = followUpToolChoice;
141
141
 
142
142
  for (let round = 1; round <= this.maxFunctionRounds; round++) {
143
- if (superseded()) {
144
- logger.info('[OpenAIResponsesProvider] turn superseded — aborting tool loop', { round, shadow });
143
+ if (shadowSuperseded()) {
144
+ logger.info('[OpenAIResponsesProvider] shadow superseded — aborting tool loop', { round });
145
145
  break;
146
146
  }
147
147
  const functionCalls = finalResponse.output.filter(item => item.type === 'function_call');
@@ -167,9 +167,9 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
167
167
  }
168
168
 
169
169
  // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
170
- // visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
170
+ // visible — but not on a shadow-supersede or delivery break, which also exit with pending calls and are not exhaustion.
171
171
  const pendingCalls = (finalResponse.output || []).filter(item => item.type === 'function_call');
172
- if (pendingCalls.length && !superseded() && !deliveryBreak) {
172
+ if (pendingCalls.length && !shadowSuperseded() && !deliveryBreak) {
173
173
  logger.warn('[OpenAIResponsesProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingCalls.map(c => c.name) });
174
174
  trace?.setSignals?.({ toolBudgetExhausted: true });
175
175
  }
@@ -1,12 +1,10 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
- const { logger } = require('../../utils/logger');
4
3
  const { retryWithBackoff } = require('../../utils/retryUtils');
5
4
  const { withTiming } = require('../../utils/tracingDecorator');
6
5
  const { safeParse } = require('../../utils/jsonUtils');
7
6
 
8
7
  const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
9
- const { isSuperseded } = require('./BaseLLMProvider');
10
8
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
11
9
 
12
10
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -39,7 +37,7 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
39
37
 
40
38
  async _invokeModel({
41
39
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
42
- modelConfig, assistant, phiProcessor = null, trace = null, shouldContinue = null
40
+ modelConfig, assistant, phiProcessor = null, trace = null
43
41
  }, options = {}) {
44
42
  const executeTools = options.toolExecutor || handleFunctionCalls;
45
43
  let totalRetries = 0;
@@ -76,10 +74,6 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
76
74
  apiCallConfig.tool_choice = followUpToolChoice;
77
75
 
78
76
  for (let round = 1; round <= this.maxFunctionRounds; round++) {
79
- if (isSuperseded(shouldContinue)) {
80
- logger.info('[OpenRouterProvider] turn superseded — aborting tool loop', { round });
81
- break;
82
- }
83
77
  const message = finalResponse.choices?.[0]?.message;
84
78
  const toolCalls = message?.tool_calls || [];
85
79
  if (!toolCalls.length) break;
@@ -25,9 +25,9 @@ function buildDedupNote(bridgeText) {
25
25
  function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, traceId = null, shouldContinue, sendMessage }) {
26
26
  if (!isBridgeEnabled()) return { sent: false };
27
27
 
28
- const skip = (reason, extra = {}) => {
28
+ const skip = (reason) => {
29
29
  logger.info('[bridge] skipped', { code, reason });
30
- return { sent: false, ...extra };
30
+ return { sent: false };
31
31
  };
32
32
 
33
33
  if (typeof shouldContinue === 'function' && !shouldContinue()) return skip('superseded');
@@ -36,44 +36,20 @@ function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, tra
36
36
  const text = sanitizeBridgeText(narrationText);
37
37
  if (!text) return skip('no_usable_narration');
38
38
 
39
- const pending = bridgeTimestamps.get(code);
40
- if (pending !== undefined) {
41
- return skip('recent_bridge_in_thread', pending.delivered ? { dedupNote: pending.dedupNote } : {});
42
- }
39
+ if (bridgeTimestamps.get(code) !== undefined) return skip('recent_bridge_in_thread');
43
40
 
44
- const dedupNote = buildDedupNote(text);
45
- const entry = { dedupNote, delivered: false, clearWhenDelivered: false };
46
- bridgeTimestamps.set(code, entry);
47
- const failed = (err) => {
48
- bridgeTimestamps.delete(code);
49
- logger.warn('[bridge] send failed', { code, error: err.message });
50
- };
51
- const delivered = () => {
52
- entry.delivered = true;
53
- if (entry.clearWhenDelivered && bridgeTimestamps.get(code) === entry) bridgeTimestamps.delete(code);
54
- };
41
+ bridgeTimestamps.set(code, true);
55
42
  try {
56
43
  Promise.resolve(sendMessage({ code, body: text, processed: true, origin: 'assistant', raw: { bridge: true }, turnId, traceId }))
57
- .then(delivered)
58
- .catch(failed);
44
+ .catch(err => logger.warn('[bridge] send failed', { code, error: err.message }));
59
45
  } catch (err) {
60
- failed(err);
46
+ logger.warn('[bridge] send failed', { code, error: err.message });
61
47
  }
62
48
 
63
49
  logger.info('[bridge] sent', { code });
64
- return { sent: true, text, dedupNote };
65
- }
66
-
67
- function clearBridgeState(code) {
68
- const pending = bridgeTimestamps.get(code);
69
- if (pending && !pending.delivered) {
70
- pending.clearWhenDelivered = true;
71
- return;
72
- }
73
- bridgeTimestamps.delete(code);
50
+ return { sent: true, text, dedupNote: buildDedupNote(text) };
74
51
  }
75
52
 
76
53
  module.exports = {
77
54
  maybeSendBridge,
78
- clearBridgeState,
79
55
  };
@@ -21,21 +21,16 @@ const INTAKE_SOURCE_TO_AIRTABLE = {
21
21
  team_relay: 'doctor',
22
22
  };
23
23
 
24
- async function auditedWrite({ trace, code, table, write, isLive = null }) {
24
+ async function auditedWrite({ trace, code, table, write }) {
25
25
  const entry = { table, at: new Date().toISOString(), ok: false };
26
- if (isLive && !isLive()) {
27
- entry.skipped = 'superseded';
28
- logger.info('[clinicalAirtableService] Airtable write skipped — turn superseded', { table, code });
29
- } else {
30
- try {
31
- const result = await write();
32
- entry.ok = true;
33
- const record = Array.isArray(result) ? result[0] : result;
34
- if (record?.id) entry.recordId = record.id;
35
- } catch (err) {
36
- entry.error = err?.message || String(err);
37
- logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
38
- }
26
+ try {
27
+ const result = await write();
28
+ entry.ok = true;
29
+ const record = Array.isArray(result) ? result[0] : result;
30
+ if (record?.id) entry.recordId = record.id;
31
+ } catch (err) {
32
+ entry.error = err?.message || String(err);
33
+ logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
39
34
  }
40
35
  if (trace?.setSignals) {
41
36
  const existing = Array.isArray(trace.signals?.airtableWrites) ? trace.signals.airtableWrites : [];
@@ -97,11 +92,10 @@ async function recordEmergency({ code, trace, symptom, suspectedCondition = '',
97
92
  });
98
93
  }
99
94
 
100
- async function recordClinicalIntake({ code, trace, messageRaw, source = 'self', isLive = null }) {
95
+ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self' }) {
101
96
  return auditedWrite({
102
97
  trace,
103
98
  code,
104
- isLive,
105
99
  table: 'intake',
106
100
  write: async () => {
107
101
  const patientId = await findIntakePatientRecordId(code);
@@ -116,13 +110,12 @@ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self',
116
110
  });
117
111
  }
118
112
 
119
- async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description, isLive = null }) {
113
+ async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description }) {
120
114
  const type = REMINDER_TYPE_TO_AIRTABLE[reminderType] || reminderType;
121
115
  if (actionType === 'update') {
122
116
  return auditedWrite({
123
117
  trace,
124
118
  code,
125
- isLive,
126
119
  table: 'reminder',
127
120
  write: async () => {
128
121
  const updated = await airtableService.updateRecordByFilter(
@@ -140,7 +133,6 @@ async function recordReminder({ code, trace, actionType, reminderId, reminderDat
140
133
  return auditedWrite({
141
134
  trace,
142
135
  code,
143
- isLive,
144
136
  table: 'reminder',
145
137
  write: () => airtableService.addLinkedRecord(
146
138
  Monitoreo_ID,
@@ -27,7 +27,7 @@ async function runShadowTurn({
27
27
  const shadowAssistant = resolveAssistant(shadowThread);
28
28
 
29
29
  const context = new ToolRuntimeContext({
30
- patientCode: code, turnId: runId, sessionId, shadow: true, shouldContinue,
30
+ patientCode: code, turnId: runId, sessionId, shadow: true,
31
31
  });
32
32
 
33
33
  const runOptions = {
@@ -153,7 +153,6 @@ async function handler(args, context = {}) {
153
153
  trace,
154
154
  messageRaw: intakeSummary,
155
155
  source: intakeSource,
156
- isLive: runtime.isLive?.bind(runtime),
157
156
  });
158
157
  }
159
158
 
@@ -107,18 +107,11 @@ async function handler(args = {}, context = {}) {
107
107
  };
108
108
  trace.setSignals({ reminderIntents: [...existing, intent] });
109
109
 
110
- const isLive = runtime.isLive?.bind(runtime);
111
- const superseded = Boolean(isLive) && !isLive();
112
-
113
110
  if (runtime.patientCode) {
114
- if (superseded) {
115
- logger.info('[schedulePatientReminder] reminder projection skipped — turn superseded', { turnId });
116
- } else {
117
- try {
118
- await dispatchReminder({ intent, patientId: runtime.patientCode });
119
- } catch (err) {
120
- logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
121
- }
111
+ try {
112
+ await dispatchReminder({ intent, patientId: runtime.patientCode });
113
+ } catch (err) {
114
+ logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
122
115
  }
123
116
  }
124
117
 
@@ -131,7 +124,6 @@ async function handler(args = {}, context = {}) {
131
124
  reminderDateUtc: fireMoment.toISOString(),
132
125
  reminderType,
133
126
  description: storedDescription,
134
- isLive,
135
127
  });
136
128
  }
137
129
 
@@ -1,5 +1,3 @@
1
- const CONTINGENCY_CATEGORY_PREFIX = 'contingency-';
2
-
3
1
  const SYMPTOM_CASE_EXT = {
4
2
  STATUS: 'symptom-case-status',
5
3
  LEGACY_ABATEMENT: 'legacy-abatement',
@@ -74,17 +72,6 @@ const PALLIATIVE_EXT = {
74
72
  FINDING_CLUSTER_NOTE: 'palliative-finding-cluster-note',
75
73
  };
76
74
 
77
- const CONTINGENCY_EXT = {
78
- STATUS: 'contingency-status',
79
- AWAITING_CHARACTERIZATION: 'contingency-awaiting-characterization',
80
- WHY_THIS_MATTERS: 'contingency-why-this-matters',
81
- CREATION_REASONING: 'contingency-creation-reasoning',
82
- RESOLVED_REASON: 'contingency-resolved-reason',
83
- STEP_PRIORITY: 'contingency-step-priority',
84
- ACTION_REQUESTED: 'contingency-action-requested',
85
- FIRES_AFTER_SILENCE_MINUTES: 'fires-after-silence-minutes',
86
- };
87
-
88
75
  const RISK_EXT = {
89
76
  PREDICTION_KIND: 'risk-prediction-kind',
90
77
  MINIMUM_SYMPTOM_SET: 'risk-minimum-symptom-set',
@@ -105,7 +92,5 @@ module.exports = {
105
92
  ROUTING_EXT,
106
93
  CLUSTER_EXT,
107
94
  PALLIATIVE_EXT,
108
- CONTINGENCY_EXT,
109
- CONTINGENCY_CATEGORY_PREFIX,
110
95
  RISK_EXT,
111
96
  };
@@ -0,0 +1,28 @@
1
+ const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
2
+ const { fhirId } = require('./fhirHelper');
3
+ const { identifier, patientReference, reference, codeableConcept, toIso } = require('./elementHelper');
4
+
5
+ function baseResource(resourceType, id, slug, patientId) {
6
+ return {
7
+ resourceType,
8
+ id: fhirId(id),
9
+ identifier: [identifier(slug, id)],
10
+ subject: patientReference(patientId),
11
+ };
12
+ }
13
+
14
+ function provenance({ provenanceId, targetRefs, recordedAt, activityText }) {
15
+ return {
16
+ resourceType: 'Provenance',
17
+ id: fhirId(provenanceId),
18
+ target: targetRefs,
19
+ recorded: toIso(recordedAt),
20
+ agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
21
+ activity: codeableConcept(activityText),
22
+ };
23
+ }
24
+
25
+ module.exports = {
26
+ baseResource,
27
+ provenance,
28
+ };
@@ -1,36 +1,193 @@
1
- const { CarePlan } = require('../resources/CarePlan');
2
- const { CommunicationRequest } = require('../resources/CommunicationRequest');
3
- const { Provenance } = require('../resources/Provenance');
1
+ const { ZodError } = require('zod');
2
+
3
+ const { getOrganizationId } = require('../config/fhirConfig');
4
+ const { fhirId } = require('../helpers/fhirHelper');
5
+ const { patientReference, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
6
+ const { baseResource, provenance } = require('../helpers/resourceHelper');
7
+ const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
8
+ const { CONTINGENCY_PLAN_SLUG, CONTINGENCY_STEP_SLUG } = require('../constants/projectionSlugs');
9
+ const { ContingencySafetyNet } = require('../../shared/dtos/ContingencySafetyNet');
10
+ const { logger } = require('../../utils/logger');
4
11
 
5
- const PROJECTOR_NAME = 'contingency';
6
12
  const ACTIVITY_TEXT = 'contingency-safety-net';
13
+ const CATEGORY_PREFIX = 'contingency-';
7
14
 
8
- function projectContingency(net) {
9
- const { carePlan, teamEscalation, patientReminder } = net;
10
- const stepArgs = {
11
- planId: carePlan.planId,
12
- patientId: carePlan.patientId,
13
- createdAt: carePlan.createdAt,
14
- parentStatus: carePlan.status,
15
- };
15
+ const EXT = {
16
+ STATUS: 'contingency-status',
17
+ AWAITING_CHARACTERIZATION: 'contingency-awaiting-characterization',
18
+ WHY_THIS_MATTERS: 'contingency-why-this-matters',
19
+ CREATION_REASONING: 'contingency-creation-reasoning',
20
+ RESOLVED_REASON: 'contingency-resolved-reason',
21
+ STEP_PRIORITY: 'contingency-step-priority',
22
+ ACTION_REQUESTED: 'contingency-action-requested',
23
+ FIRES_AFTER_SILENCE_MINUTES: 'fires-after-silence-minutes',
24
+ };
25
+
26
+ const STATUS_TO_FHIR = {
27
+ armed: 'active',
28
+ fired_reminder: 'active',
29
+ fired_team: 'active',
30
+ auto_resolved: 'completed',
31
+ cancelled: 'revoked',
32
+ };
16
33
 
34
+ const PRIORITY_CROSSWALK = {
35
+ critical: 'stat',
36
+ high: 'urgent',
37
+ medium: 'routine',
38
+ low: 'routine',
39
+ routine: 'routine',
40
+ urgent: 'urgent',
41
+ asap: 'asap',
42
+ stat: 'stat',
43
+ };
44
+
45
+ const RESOURCE_TYPES = ['CarePlan', 'CommunicationRequest'];
46
+
47
+ function toFhir(contingency) {
48
+ const { carePlan, teamEscalation, patientReminder } = contingency;
17
49
  const steps = [teamEscalation];
18
50
  if (patientReminder) steps.push(patientReminder);
19
51
 
20
- const out = [CarePlan.fromContingency({ carePlan })];
52
+ const resources = [carePlanResource(carePlan)];
53
+ for (const step of steps) resources.push(stepResource(step, carePlan));
54
+
55
+ const targetRefs = [{ reference: `CarePlan/${fhirId(carePlan.planId)}` }];
21
56
  for (const step of steps) {
22
- out.push(CommunicationRequest.fromContingencyStep({ step, ...stepArgs }));
57
+ targetRefs.push({ reference: `CommunicationRequest/${fhirId(`${carePlan.planId}-${step.recipient}`)}` });
23
58
  }
24
- out.push(Provenance.fromContingency({
25
- planId: carePlan.planId,
26
- recipients: steps.map((step) => step.recipient),
59
+ resources.push(provenance({
60
+ provenanceId: `${carePlan.planId}-prov`,
61
+ targetRefs,
27
62
  recordedAt: carePlan.createdAt,
28
63
  activityText: ACTIVITY_TEXT,
29
64
  }));
65
+ return resources;
66
+ }
67
+
68
+ function carePlanResource(carePlan) {
69
+ const payload = {
70
+ ...baseResource('CarePlan', carePlan.planId, CONTINGENCY_PLAN_SLUG, carePlan.patientId),
71
+ status: STATUS_TO_FHIR[carePlan.status],
72
+ intent: 'plan',
73
+ created: toIso(carePlan.createdAt),
74
+ period: carePlanPeriod(carePlan),
75
+ };
76
+ if (carePlan.concern) payload.description = carePlan.concern;
77
+ if (carePlan.relatedCaseId) payload.addresses = [{ reference: reference('Condition', carePlan.relatedCaseId) }];
78
+ if (carePlan.relatedClusterId) payload.supportingInfo = [reference('ClinicalImpression', carePlan.relatedClusterId)];
79
+ const ext = carePlanExtensions(carePlan);
80
+ if (ext.length) payload.extension = ext;
81
+ return payload;
82
+ }
83
+
84
+ function carePlanPeriod(carePlan) {
85
+ const period = { start: toIso(carePlan.createdAt) };
86
+ if (carePlan.resolvedAt) period.end = toIso(carePlan.resolvedAt);
87
+ return period;
88
+ }
89
+
90
+ function carePlanExtensions(carePlan) {
91
+ const out = [extension(EXT.STATUS, { valueString: carePlan.status })];
92
+ if (carePlan.awaitingCharacterization) out.push(extension(EXT.AWAITING_CHARACTERIZATION, { valueString: carePlan.awaitingCharacterization }));
93
+ if (carePlan.whyThisMatters) out.push(extension(EXT.WHY_THIS_MATTERS, { valueString: carePlan.whyThisMatters }));
94
+ if (carePlan.creationReasoning) out.push(extension(EXT.CREATION_REASONING, { valueString: carePlan.creationReasoning }));
95
+ if (carePlan.resolvedReason) out.push(extension(EXT.RESOLVED_REASON, { valueString: carePlan.resolvedReason }));
30
96
  return out;
31
97
  }
32
98
 
99
+ function stepResource(step, carePlan) {
100
+ const stepId = `${carePlan.planId}-${step.recipient}`;
101
+ const payload = {
102
+ ...baseResource('CommunicationRequest', stepId, CONTINGENCY_STEP_SLUG, carePlan.patientId),
103
+ status: STATUS_TO_FHIR[carePlan.status],
104
+ intent: 'plan',
105
+ category: [codeableConcept(`${CATEGORY_PREFIX}${step.recipient}`)],
106
+ recipient: [step.recipient === 'patient'
107
+ ? patientReference(carePlan.patientId)
108
+ : { reference: `Organization/${fhirId(getOrganizationId())}` }],
109
+ payload: [{ contentCodeableConcept: codeableConcept(step.message) }],
110
+ authoredOn: toIso(carePlan.createdAt),
111
+ basedOn: [{ reference: `CarePlan/${fhirId(carePlan.planId)}` }],
112
+ };
113
+ const priority = step.priority ? PRIORITY_CROSSWALK[step.priority] || 'routine' : null;
114
+ if (priority) payload.priority = priority;
115
+ const ext = stepExtensions(step);
116
+ if (ext.length) payload.extension = ext;
117
+ return payload;
118
+ }
119
+
120
+ function stepExtensions(step) {
121
+ const out = [];
122
+ if (step.priority) out.push(extension(EXT.STEP_PRIORITY, { valueString: step.priority }));
123
+ if (step.actionRequested) out.push(extension(EXT.ACTION_REQUESTED, { valueString: step.actionRequested }));
124
+ if (step.firesAfterSilenceMinutes !== null && step.firesAfterSilenceMinutes !== undefined) {
125
+ out.push(extension(EXT.FIRES_AFTER_SILENCE_MINUTES, { valueInteger: step.firesAfterSilenceMinutes }));
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function fromFhir(resources, { patientId }) {
131
+ const requests = resources.filter((r) => r.resourceType === 'CommunicationRequest' && identifierValue(r, CONTINGENCY_STEP_SLUG));
132
+ return resources
133
+ .filter((r) => r.resourceType === 'CarePlan' && identifierValue(r, CONTINGENCY_PLAN_SLUG))
134
+ .map((plan) => reconstruct(plan, requests, patientId))
135
+ .filter(Boolean);
136
+ }
137
+
138
+ function reconstruct(plan, requests, patientId) {
139
+ const planId = identifierValue(plan, CONTINGENCY_PLAN_SLUG);
140
+ const ext = extMap(plan);
141
+ const stepFor = (recipient) => {
142
+ const resource = requests.find((r) => identifierValue(r, CONTINGENCY_STEP_SLUG) === `${planId}-${recipient}`);
143
+ return resource ? reconstructStep(resource, recipient) : null;
144
+ };
145
+ const address = plan.addresses && plan.addresses[0] && plan.addresses[0].reference;
146
+ const reconstructed = {
147
+ schemaVersion: '1',
148
+ carePlan: {
149
+ planId,
150
+ patientId,
151
+ relatedCaseId: refToId(address && address.reference),
152
+ relatedClusterId: refToId(plan.supportingInfo && plan.supportingInfo[0] && plan.supportingInfo[0].reference),
153
+ concern: plan.description || '',
154
+ awaitingCharacterization: ext[EXT.AWAITING_CHARACTERIZATION] || '',
155
+ whyThisMatters: ext[EXT.WHY_THIS_MATTERS] || '',
156
+ creationReasoning: ext[EXT.CREATION_REASONING] || '',
157
+ status: ext[EXT.STATUS] || 'armed',
158
+ createdAt: plan.created,
159
+ resolvedAt: (plan.period && plan.period.end) || null,
160
+ resolvedReason: ext[EXT.RESOLVED_REASON] || null,
161
+ },
162
+ teamEscalation: stepFor('care_team'),
163
+ patientReminder: stepFor('patient'),
164
+ };
165
+ try {
166
+ return new ContingencySafetyNet(reconstructed);
167
+ } catch (error) {
168
+ if (error instanceof ZodError) {
169
+ logger.warn({ planId }, 'skipping malformed contingency CarePlan');
170
+ return null;
171
+ }
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ function reconstructStep(resource, recipient) {
177
+ const ext = extMap(resource);
178
+ const silence = ext[EXT.FIRES_AFTER_SILENCE_MINUTES];
179
+ const firstPayload = resource.payload && resource.payload[0];
180
+ return {
181
+ recipient,
182
+ message: (firstPayload && firstPayload.contentCodeableConcept && firstPayload.contentCodeableConcept.text) || null,
183
+ priority: ext[EXT.STEP_PRIORITY] || resource.priority || null,
184
+ actionRequested: ext[EXT.ACTION_REQUESTED] || null,
185
+ firesAfterSilenceMinutes: typeof silence === 'number' ? silence : null,
186
+ };
187
+ }
188
+
33
189
  module.exports = {
34
- projectContingency,
35
- PROJECTOR_NAME,
190
+ toFhir,
191
+ fromFhir,
192
+ RESOURCE_TYPES,
36
193
  };
@@ -6,7 +6,6 @@ const { projectSymptomCase, PROJECTOR_NAME: SYMPTOM_CASE_PROJECTOR_NAME } = requ
6
6
  const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
7
7
  const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
8
8
  const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
9
- const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = require('./contingencyProjection');
10
9
  const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
11
10
  const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
12
11
  const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
@@ -21,7 +20,6 @@ const PROJECTORS = [
21
20
  { name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
22
21
  { name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
23
22
  { name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
24
- { name: CONTINGENCY_PROJECTOR_NAME, project: projectContingency },
25
23
  { name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
26
24
  { name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
27
25
  { name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
@@ -7,45 +7,14 @@ const {
7
7
  extension,
8
8
  toIso,
9
9
  } = require('../helpers/elementHelper');
10
- const { CONTINGENCY_PLAN_SLUG, ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
11
- const { CONTINGENCY_EXT, PALLIATIVE_EXT } = require('../constants/extensionSlugs');
12
-
13
- const CONTINGENCY_STATUS_TO_FHIR = {
14
- armed: 'active',
15
- fired_reminder: 'active',
16
- fired_team: 'active',
17
- auto_resolved: 'completed',
18
- cancelled: 'revoked',
19
- };
10
+ const { ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
11
+ const { PALLIATIVE_EXT } = require('../constants/extensionSlugs');
20
12
 
21
13
  class CarePlan {
22
14
  constructor(payload) {
23
15
  Object.assign(this, payload);
24
16
  }
25
17
 
26
- static fromContingency({ carePlan }) {
27
- const payload = {
28
- resourceType: 'CarePlan',
29
- id: fhirId(carePlan.planId),
30
- identifier: [identifier(CONTINGENCY_PLAN_SLUG, carePlan.planId)],
31
- status: CONTINGENCY_STATUS_TO_FHIR[carePlan.status],
32
- intent: 'plan',
33
- subject: patientReference(carePlan.patientId),
34
- created: toIso(carePlan.createdAt),
35
- period: contingencyPeriod(carePlan),
36
- };
37
- if (carePlan.concern) payload.description = carePlan.concern;
38
- if (carePlan.relatedCaseId) {
39
- payload.addresses = [{ reference: reference('Condition', carePlan.relatedCaseId) }];
40
- }
41
- if (carePlan.relatedClusterId) {
42
- payload.supportingInfo = [reference('ClinicalImpression', carePlan.relatedClusterId)];
43
- }
44
- const ext = contingencyExtensions(carePlan);
45
- if (ext.length) payload.extension = ext;
46
- return new CarePlan(payload);
47
- }
48
-
49
18
  static fromRecommendationPlan({ plan, taskIds, hasSafetyGate }) {
50
19
  const payload = {
51
20
  resourceType: 'CarePlan',
@@ -110,30 +79,6 @@ class CarePlan {
110
79
  }
111
80
  }
112
81
 
113
- function contingencyPeriod(carePlan) {
114
- const period = { start: toIso(carePlan.createdAt) };
115
- if (carePlan.resolvedAt) period.end = toIso(carePlan.resolvedAt);
116
- return period;
117
- }
118
-
119
- function contingencyExtensions(carePlan) {
120
- const out = [extension(CONTINGENCY_EXT.STATUS, { valueString: carePlan.status })];
121
- if (carePlan.awaitingCharacterization) {
122
- out.push(extension(CONTINGENCY_EXT.AWAITING_CHARACTERIZATION, { valueString: carePlan.awaitingCharacterization }));
123
- }
124
- if (carePlan.whyThisMatters) {
125
- out.push(extension(CONTINGENCY_EXT.WHY_THIS_MATTERS, { valueString: carePlan.whyThisMatters }));
126
- }
127
- if (carePlan.creationReasoning) {
128
- out.push(extension(CONTINGENCY_EXT.CREATION_REASONING, { valueString: carePlan.creationReasoning }));
129
- }
130
- if (carePlan.resolvedReason) {
131
- out.push(extension(CONTINGENCY_EXT.RESOLVED_REASON, { valueString: carePlan.resolvedReason }));
132
- }
133
- return out;
134
- }
135
-
136
82
  module.exports = {
137
83
  CarePlan,
138
- CONTINGENCY_STATUS_TO_FHIR,
139
84
  };
@@ -1,4 +1,3 @@
1
- const { getOrganizationId } = require('../config/fhirConfig');
2
1
  const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
3
2
  const {
4
3
  identifier,
@@ -16,25 +15,11 @@ const {
16
15
  REMINDER_SLUG,
17
16
  REMINDER_KIND_SLUG,
18
17
  COMMUNICATION_CHANNEL_SLUG,
19
- CONTINGENCY_STEP_SLUG,
20
18
  INTERVENTION_SLUG,
21
19
  INTERVENTION_KIND_SLUG,
22
20
  ROUTING_DISPOSITION_SLUG,
23
21
  } = require('../constants/projectionSlugs');
24
- const { CONTINGENCY_EXT, CONTINGENCY_CATEGORY_PREFIX } = require('../constants/extensionSlugs');
25
22
  const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
26
- const { CONTINGENCY_STATUS_TO_FHIR } = require('./CarePlan');
27
-
28
- const CONTINGENCY_PRIORITY_CROSSWALK = {
29
- critical: 'stat',
30
- high: 'urgent',
31
- medium: 'routine',
32
- low: 'routine',
33
- routine: 'routine',
34
- urgent: 'urgent',
35
- asap: 'asap',
36
- stat: 'stat',
37
- };
38
23
  const { interventionExtensions } = require('./interventionExtensions');
39
24
 
40
25
  const NOTIFY_STATUS_TO_CR_STATUS = {
@@ -106,35 +91,6 @@ class CommunicationRequest {
106
91
  return new CommunicationRequest(payload);
107
92
  }
108
93
 
109
- static fromContingencyStep({ step, planId, patientId, createdAt, parentStatus }) {
110
- const stepId = `${planId}-${step.recipient}`;
111
- const payload = {
112
- resourceType: 'CommunicationRequest',
113
- id: fhirId(stepId),
114
- identifier: [identifier(CONTINGENCY_STEP_SLUG, stepId)],
115
- status: CONTINGENCY_STATUS_TO_FHIR[parentStatus],
116
- intent: 'plan',
117
- category: [codeableConcept(`${CONTINGENCY_CATEGORY_PREFIX}${step.recipient}`)],
118
- subject: patientReference(patientId),
119
- recipient: [step.recipient === 'patient'
120
- ? patientReference(patientId)
121
- : { reference: `Organization/${fhirId(getOrganizationId())}` }],
122
- payload: [{ contentCodeableConcept: codeableConcept(step.message) }],
123
- authoredOn: toIso(createdAt),
124
- basedOn: [{ reference: `CarePlan/${fhirId(planId)}` }],
125
- };
126
- const priority = step.priority ? CONTINGENCY_PRIORITY_CROSSWALK[step.priority] || 'routine' : null;
127
- if (priority) payload.priority = priority;
128
- const ext = [];
129
- if (step.priority) ext.push(extension(CONTINGENCY_EXT.STEP_PRIORITY, { valueString: step.priority }));
130
- if (step.actionRequested) ext.push(extension(CONTINGENCY_EXT.ACTION_REQUESTED, { valueString: step.actionRequested }));
131
- if (step.firesAfterSilenceMinutes !== null && step.firesAfterSilenceMinutes !== undefined) {
132
- ext.push(extension(CONTINGENCY_EXT.FIRES_AFTER_SILENCE_MINUTES, { valueInteger: step.firesAfterSilenceMinutes }));
133
- }
134
- if (ext.length) payload.extension = ext;
135
- return new CommunicationRequest(payload);
136
- }
137
-
138
94
  static fromIntervention({ intervention }) {
139
95
  const payload = {
140
96
  resourceType: 'CommunicationRequest',
@@ -109,21 +109,6 @@ class Provenance {
109
109
  });
110
110
  }
111
111
 
112
- static fromContingency({ planId, recipients, recordedAt, activityText }) {
113
- const targets = [{ reference: `CarePlan/${fhirId(planId)}` }];
114
- for (const recipient of recipients) {
115
- targets.push({ reference: `CommunicationRequest/${fhirId(`${planId}-${recipient}`)}` });
116
- }
117
- return new Provenance({
118
- resourceType: 'Provenance',
119
- id: fhirId(`${planId}-prov`),
120
- target: targets,
121
- recorded: toIso(recordedAt),
122
- agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
123
- activity: codeableConcept(activityText),
124
- });
125
- }
126
-
127
112
  static fromResultObservations({ targetIds, provenanceId, activityText, now }) {
128
113
  return new Provenance({
129
114
  resourceType: 'Provenance',
@@ -1,117 +1,23 @@
1
- const { ZodError } = require('zod');
2
-
3
1
  const { fhirId } = require('../helpers/fhirHelper');
4
- const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
5
- const {
6
- CONTINGENCY_PLAN_SLUG,
7
- CONTINGENCY_STEP_SLUG,
8
- } = require('../constants/projectionSlugs');
9
- const { CONTINGENCY_EXT, CONTINGENCY_CATEGORY_PREFIX } = require('../constants/extensionSlugs');
10
- const { CONTINGENCY_STATUS_TO_FHIR } = require('../resources/CarePlan');
11
- const { PROJECTOR_NAME } = require('../projections/contingencyProjection');
12
2
  const { getFhirStore } = require('../stores/fhirStore');
13
- const { ContingencySafetyNet, RECIPIENTS } = require('../../shared/dtos/ContingencySafetyNet');
14
- const { logger } = require('../../utils/logger');
15
- const { project, storeResources } = require('./fhirService');
16
-
17
- const FHIR_TO_CONTINGENCY_STATUS = invert(CONTINGENCY_STATUS_TO_FHIR);
3
+ const { toFhir, fromFhir, RESOURCE_TYPES } = require('../projections/contingencyProjection');
4
+ const { storeResources } = require('./fhirService');
18
5
 
19
6
  async function storeContingency({ patientId, contingency }) {
20
7
  if (contingency.carePlan.patientId !== patientId) {
21
8
  throw new Error(`storeContingency patientId mismatch: expected ${patientId}`);
22
9
  }
23
- const bundle = project([{ name: PROJECTOR_NAME, aggregate: contingency }]);
24
- const resources = bundle.entry.map((entry) => entry.resource);
25
- const { stored } = await storeResources(resources);
10
+ const { stored } = await storeResources(toFhir(contingency));
26
11
  return { count: stored.length, ids: stored };
27
12
  }
28
13
 
29
14
  async function readContingencies({ patientId, caseId = null, clusterId = null }) {
30
15
  const store = getFhirStore();
31
16
  const patient = `Patient/${fhirId(patientId)}`;
32
- const plans = (await store.find({ patient, resourceType: 'CarePlan' }))
33
- .filter((plan) => identifierValue(plan, CONTINGENCY_PLAN_SLUG));
34
- const requests = (await store.find({ patient, resourceType: 'CommunicationRequest' }))
35
- .filter((request) => identifierValue(request, CONTINGENCY_STEP_SLUG));
36
-
37
- const nets = [];
38
- for (const plan of plans) {
39
- const net = reconstructContingency(plan, requests, patientId);
40
- if (caseId && net.carePlan.relatedCaseId !== caseId) continue;
41
- if (clusterId && net.carePlan.relatedClusterId !== clusterId) continue;
42
- try {
43
- nets.push(new ContingencySafetyNet(net));
44
- } catch (error) {
45
- if (error instanceof ZodError) {
46
- logger.warn({ planId: net.carePlan.planId }, 'skipping malformed contingency CarePlan');
47
- continue;
48
- }
49
- throw error;
50
- }
51
- }
52
- return nets;
53
- }
54
-
55
- function reconstructContingency(plan, requests, patientId) {
56
- const planId = identifierValue(plan, CONTINGENCY_PLAN_SLUG);
57
- const ext = extMap(plan);
58
- const stepIds = new Set([`${planId}-care_team`, `${planId}-patient`]);
59
- const planSteps = requests
60
- .filter((resource) => stepIds.has(identifierValue(resource, CONTINGENCY_STEP_SLUG)))
61
- .map(reconstructStep);
62
- const teamEscalation = planSteps.find((step) => step.recipient === 'care_team') || null;
63
- const patientReminder = planSteps.find((step) => step.recipient === 'patient') || null;
64
- const address = plan.addresses && plan.addresses[0] && plan.addresses[0].reference;
65
- return {
66
- schemaVersion: '1',
67
- carePlan: {
68
- planId,
69
- patientId,
70
- relatedCaseId: refToId(address && address.reference),
71
- relatedClusterId: refToId(plan.supportingInfo && plan.supportingInfo[0] && plan.supportingInfo[0].reference),
72
- concern: plan.description || '',
73
- awaitingCharacterization: ext[CONTINGENCY_EXT.AWAITING_CHARACTERIZATION] || '',
74
- whyThisMatters: ext[CONTINGENCY_EXT.WHY_THIS_MATTERS] || '',
75
- creationReasoning: ext[CONTINGENCY_EXT.CREATION_REASONING] || '',
76
- status: ext[CONTINGENCY_EXT.STATUS] || FHIR_TO_CONTINGENCY_STATUS[plan.status] || 'armed',
77
- createdAt: plan.created,
78
- resolvedAt: (plan.period && plan.period.end) || null,
79
- resolvedReason: ext[CONTINGENCY_EXT.RESOLVED_REASON] || null,
80
- },
81
- teamEscalation,
82
- patientReminder,
83
- };
84
- }
85
-
86
- function reconstructStep(resource) {
87
- const ext = extMap(resource);
88
- const silence = ext[CONTINGENCY_EXT.FIRES_AFTER_SILENCE_MINUTES];
89
- const firstPayload = resource.payload && resource.payload[0];
90
- return {
91
- recipient: recipientOf(resource),
92
- message: (firstPayload && firstPayload.contentCodeableConcept && firstPayload.contentCodeableConcept.text) || null,
93
- priority: ext[CONTINGENCY_EXT.STEP_PRIORITY] || resource.priority || null,
94
- actionRequested: ext[CONTINGENCY_EXT.ACTION_REQUESTED] || null,
95
- firesAfterSilenceMinutes: typeof silence === 'number' ? silence : null,
96
- };
97
- }
98
-
99
- function recipientOf(resource) {
100
- const category = resource.category && resource.category[0] && resource.category[0].text;
101
- if (category && category.startsWith(CONTINGENCY_CATEGORY_PREFIX)) {
102
- const recipient = category.slice(CONTINGENCY_CATEGORY_PREFIX.length);
103
- if (RECIPIENTS.includes(recipient)) return recipient;
104
- }
105
- const ref = resource.recipient && resource.recipient[0] && resource.recipient[0].reference;
106
- return ref && ref.startsWith('Organization/') ? 'care_team' : 'patient';
107
- }
108
-
109
- function invert(map) {
110
- const out = {};
111
- for (const [key, value] of Object.entries(map)) {
112
- if (!(value in out)) out[value] = key;
113
- }
114
- return out;
17
+ const found = await Promise.all(RESOURCE_TYPES.map((resourceType) => store.find({ patient, resourceType })));
18
+ return fromFhir(found.flat(), { patientId })
19
+ .filter((contingency) => !caseId || contingency.carePlan.relatedCaseId === caseId)
20
+ .filter((contingency) => !clusterId || contingency.carePlan.relatedClusterId === clusterId);
115
21
  }
116
22
 
117
23
  module.exports = {
@@ -52,5 +52,4 @@ module.exports = {
52
52
  ContingencySafetyNet,
53
53
  ESCALATION_PRIORITIES,
54
54
  ACTIVE_CARE_PLAN_STATUSES,
55
- RECIPIENTS,
56
55
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.11.0-dev.1128",
3
+ "version": "5.11.0-dev.1130",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",