@peopl-health/nexus 5.11.0-dev.1127 → 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.
@@ -11,59 +11,26 @@ const TOOLS_TABLE = 'tools';
11
11
  const CACHE_TTL = 5 * 60 * 1000;
12
12
  const CACHE_KEY = 'skills';
13
13
  const TOOLS_CACHE_KEY = 'toolCatalog';
14
- const BACKTICK_SPAN_RX = /`([^`\n]+)`/g;
15
- const LEADING_CALL_RX = /^([A-Za-z][A-Za-z0-9]*)\s*(?:[(.]|$)/;
16
- const MULTI_WORD_RX = /[a-z][A-Z]/;
17
14
 
18
15
  const cache = new MapCache({ maxSize: 2, ttl: CACHE_TTL });
19
16
 
20
- async function readToolCatalog() {
21
- const cached = cache.get(TOOLS_CACHE_KEY);
22
- if (cached) return cached;
23
-
24
- let records;
25
- try {
26
- records = await getRecordByFilter(Config_ID, TOOLS_TABLE, 'TRUE()');
27
- } catch (error) {
28
- logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { error: error.message });
29
- return null;
30
- }
31
- if (!records) {
32
- logger.warn('[skillService] tool catalog unreadable; drift check falls back to the skill body', { table: TOOLS_TABLE });
33
- return null;
34
- }
35
-
36
- const catalog = records.map((row) => row.tool_id).filter(Boolean);
37
- cache.set(TOOLS_CACHE_KEY, catalog);
38
- return catalog;
39
- }
40
-
41
17
  async function unavailableToolNames() {
42
- const catalog = await readToolCatalog();
43
- if (!catalog) return [];
18
+ let catalog = cache.get(TOOLS_CACHE_KEY);
19
+ if (!catalog) {
20
+ const records = await getRecordByFilter(Config_ID, TOOLS_TABLE, 'TRUE()');
21
+ catalog = (records || []).map((row) => row.tool_id).filter(Boolean);
22
+ cache.set(TOOLS_CACHE_KEY, catalog);
23
+ }
44
24
  const registered = new Set(getRegisteredToolNames());
45
25
  return catalog.filter((id) => !registered.has(id));
46
26
  }
47
27
 
48
- function mentionedToolNames(text) {
49
- const names = new Set();
50
- for (const [, span] of text.matchAll(BACKTICK_SPAN_RX)) {
51
- const id = span.match(LEADING_CALL_RX)?.[1];
52
- if (id && MULTI_WORD_RX.test(id)) names.add(id);
53
- }
54
- return names;
55
- }
56
-
57
28
  async function warnOnRegistryDrift(name, body) {
58
- const registeredNames = getRegisteredToolNames();
59
- if (registeredNames.length === 0) return;
60
- const registered = new Set(registeredNames);
29
+ if (getRegisteredToolNames().length === 0) return;
61
30
  const text = String(body || '');
62
-
63
- const fromCatalog = (await unavailableToolNames()).filter((id) => new RegExp(`\\b${id}\\b`).test(text));
64
- const fromBody = [...mentionedToolNames(text)].filter((id) => !registered.has(id));
65
- const missing = [...new Set([...fromCatalog, ...fromBody])].sort();
66
-
31
+ const missing = (await unavailableToolNames())
32
+ .filter((id) => new RegExp(`\\b${id}\\b`).test(text))
33
+ .sort();
67
34
  if (missing.length > 0) {
68
35
  logger.error('[skillService] Skill body calls tools that are not registered — the model cannot follow this body', {
69
36
  skill: name, missing,
@@ -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.1127",
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",