@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.
- package/lib/clinical/services/skillService.js +10 -43
- package/lib/fhir/constants/extensionSlugs.js +0 -15
- package/lib/fhir/helpers/resourceHelper.js +28 -0
- package/lib/fhir/projections/contingencyProjection.js +176 -19
- package/lib/fhir/projections/registerProjectors.js +0 -2
- package/lib/fhir/resources/CarePlan.js +2 -57
- package/lib/fhir/resources/CommunicationRequest.js +0 -44
- package/lib/fhir/resources/Provenance.js +0 -15
- package/lib/fhir/services/contingencyService.js +7 -101
- package/lib/shared/dtos/ContingencySafetyNet.js +0 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
43
|
-
if (!catalog)
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
const {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
57
|
+
targetRefs.push({ reference: `CommunicationRequest/${fhirId(`${carePlan.planId}-${step.recipient}`)}` });
|
|
23
58
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 {
|
|
11
|
-
const {
|
|
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 {
|
|
14
|
-
const {
|
|
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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.filter((
|
|
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 = {
|