@peopl-health/nexus 5.12.0 → 5.12.1
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/AssistantProcessor.js +1 -1
- package/lib/clinical/tools/submitRoutingDecisionTool.js +2 -2
- package/lib/clinical/tools/updateConditionStatusTool.js +1 -2
- package/lib/fhir/helpers/resourceHelper.js +13 -4
- package/lib/fhir/index.js +2 -2
- package/lib/fhir/models/envelopeModel.js +19 -0
- package/lib/fhir/projections/clusterProjection.js +113 -14
- package/lib/fhir/projections/contingencyProjection.js +1 -1
- package/lib/fhir/projections/interventionProjection.js +225 -23
- package/lib/fhir/projections/registerProjectors.js +0 -8
- package/lib/fhir/projections/routingProjection.js +276 -40
- package/lib/fhir/projections/symptomCaseProjection.js +272 -48
- package/lib/fhir/resources/CarePlan.js +1 -18
- package/lib/fhir/resources/ClinicalImpression.js +3 -115
- package/lib/fhir/resources/CommunicationRequest.js +0 -48
- package/lib/fhir/resources/Condition.js +1 -85
- package/lib/fhir/resources/Observation.js +1 -31
- package/lib/fhir/resources/Procedure.js +2 -34
- package/lib/fhir/resources/Provenance.js +1 -33
- package/lib/fhir/resources/Task.js +0 -50
- package/lib/fhir/services/clusterService.js +12 -60
- package/lib/fhir/services/interventionService.js +7 -131
- package/lib/fhir/services/routingService.js +18 -130
- package/lib/fhir/services/symptomCaseService.js +11 -121
- package/lib/fhir/stores/fhirStore.js +3 -1
- package/lib/shared/dtos/ManagedSymptom.js +4 -0
- package/package.json +1 -1
- package/lib/fhir/resources/Communication.js +0 -65
- package/lib/fhir/resources/RequestOrchestration.js +0 -46
- package/lib/fhir/resources/interventionExtensions.js +0 -28
|
@@ -43,7 +43,7 @@ class AssistantProcessor {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
if (
|
|
46
|
+
if (!runOptions.toolRuntimeContext && runOptions.runId && thread.code) {
|
|
47
47
|
try {
|
|
48
48
|
runOptions.toolRuntimeContext = new ToolRuntimeContext({
|
|
49
49
|
patientCode: thread.code,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const crypto = require('node:crypto');
|
|
2
2
|
|
|
3
|
-
const { storeRoutingDecision,
|
|
3
|
+
const { storeRoutingDecision, readRoutingDecisions } = require('../../fhir');
|
|
4
4
|
const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
|
|
5
5
|
|
|
6
6
|
const _ROUTING_ACTIONS = [
|
|
@@ -273,7 +273,7 @@ async function handler(args = {}, context = {}) {
|
|
|
273
273
|
const patientId = runtime.patientCode;
|
|
274
274
|
const turnId = runtime.turnId;
|
|
275
275
|
|
|
276
|
-
const existing = await
|
|
276
|
+
const [existing] = await readRoutingDecisions({ patientId, turnId });
|
|
277
277
|
if (existing) {
|
|
278
278
|
return fail('decision_already_committed_for_turn', { existing_decision_id: existing.decisionId, committed_at: existing.committedAt });
|
|
279
279
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const { readSymptomCases, storeSymptomCase, readContingencies, storeContingency, readClusters, storeCluster } = require('../../fhir');
|
|
2
|
-
const { ManagedSymptom, OPEN_STATUSES } = require('../../shared/dtos/ManagedSymptom');
|
|
3
|
-
const { RESOLUTION_CLOSE_REASONS } = require('../../fhir/resources/Condition');
|
|
2
|
+
const { ManagedSymptom, OPEN_STATUSES, RESOLUTION_CLOSE_REASONS } = require('../../shared/dtos/ManagedSymptom');
|
|
4
3
|
const { ContingencySafetyNet, ACTIVE_CARE_PLAN_STATUSES } = require('../../shared/dtos/ContingencySafetyNet');
|
|
5
4
|
const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
|
|
6
5
|
const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
|
|
1
|
+
const { getDeviceId, getOrganizationId, getSystems } = require('../config/fhirConfig');
|
|
2
2
|
const { fhirId } = require('./fhirHelper');
|
|
3
3
|
const { identifier, patientReference, reference, codeableConcept, toIso } = require('./elementHelper');
|
|
4
4
|
|
|
@@ -11,18 +11,27 @@ function baseResource(resourceType, id, slug, patientId) {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function provenance({ provenanceId, targetRefs, recordedAt,
|
|
14
|
+
function provenance({ provenanceId, targetRefs, recordedAt, activity, agentDevice = null, onBehalfOf = null }) {
|
|
15
15
|
return {
|
|
16
16
|
resourceType: 'Provenance',
|
|
17
17
|
id: fhirId(provenanceId),
|
|
18
18
|
target: targetRefs,
|
|
19
19
|
recorded: toIso(recordedAt),
|
|
20
|
-
agent: [{
|
|
21
|
-
|
|
20
|
+
agent: [{
|
|
21
|
+
who: reference('Device', agentDevice || getDeviceId()),
|
|
22
|
+
onBehalfOf: reference('Organization', onBehalfOf || getOrganizationId()),
|
|
23
|
+
}],
|
|
24
|
+
activity,
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
function observationCategory(code) {
|
|
29
|
+
const label = code.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
30
|
+
return codeableConcept(label, { code, system: getSystems().observationCategory, display: label });
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
module.exports = {
|
|
26
34
|
baseResource,
|
|
27
35
|
provenance,
|
|
36
|
+
observationCategory,
|
|
28
37
|
};
|
package/lib/fhir/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
const { storeContingency, readContingencies } = require('./services/contingencyService');
|
|
13
13
|
const { storeIntervention, readInterventions } = require('./services/interventionService');
|
|
14
14
|
const { storeCluster, readClusters } = require('./services/clusterService');
|
|
15
|
-
const { storeRoutingDecision,
|
|
15
|
+
const { storeRoutingDecision, readRoutingDecisions } = require('./services/routingService');
|
|
16
16
|
const {
|
|
17
17
|
storePalliativeAssessment,
|
|
18
18
|
readPalliativeByTurn,
|
|
@@ -55,7 +55,7 @@ module.exports = {
|
|
|
55
55
|
storeCluster,
|
|
56
56
|
readClusters,
|
|
57
57
|
storeRoutingDecision,
|
|
58
|
-
|
|
58
|
+
readRoutingDecisions,
|
|
59
59
|
storePalliativeAssessment,
|
|
60
60
|
readPalliativeByTurn,
|
|
61
61
|
storePalliativeCarePlan,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const mongoose = require('mongoose');
|
|
2
2
|
|
|
3
3
|
const SYMPTOM_SYSTEM_SUFFIX = '/pro-ctcae';
|
|
4
|
+
const TURN_ID_EXT_SUFFIX = '-turn-id';
|
|
4
5
|
|
|
5
6
|
const envelopeSchema = new mongoose.Schema(
|
|
6
7
|
{
|
|
@@ -11,6 +12,7 @@ const envelopeSchema = new mongoose.Schema(
|
|
|
11
12
|
date: { type: Date, default: null },
|
|
12
13
|
reference: { type: String, default: null },
|
|
13
14
|
symptomTerm: { type: String, default: null },
|
|
15
|
+
turnId: { type: String, default: null },
|
|
14
16
|
},
|
|
15
17
|
resource: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
16
18
|
meta: {
|
|
@@ -23,6 +25,14 @@ const envelopeSchema = new mongoose.Schema(
|
|
|
23
25
|
|
|
24
26
|
envelopeSchema.index({ 'index.patient': 1, 'index.date': -1 });
|
|
25
27
|
envelopeSchema.index({ 'index.patient': 1, 'index.symptomTerm': 1, 'index.date': -1 });
|
|
28
|
+
envelopeSchema.index(
|
|
29
|
+
{ 'index.patient': 1, 'resource.identifier.system': 1, 'index.date': -1 },
|
|
30
|
+
{ partialFilterExpression: { 'meta.deleted': false } },
|
|
31
|
+
);
|
|
32
|
+
envelopeSchema.index(
|
|
33
|
+
{ 'index.patient': 1, 'index.turnId': 1, 'index.date': -1 },
|
|
34
|
+
{ partialFilterExpression: { 'meta.deleted': false, 'index.turnId': { $type: 'string' } } },
|
|
35
|
+
);
|
|
26
36
|
envelopeSchema.index({ 'index.reference': 1 }, { partialFilterExpression: { 'index.reference': { $type: 'string' } } });
|
|
27
37
|
envelopeSchema.index({ 'resource.status': 1 }, { sparse: true });
|
|
28
38
|
|
|
@@ -63,6 +73,7 @@ envelopeSchema.statics.deriveIndex = function deriveIndex(resource) {
|
|
|
63
73
|
date: instantOf(date),
|
|
64
74
|
reference: resource.resourceType && resource.id ? `${resource.resourceType}/${resource.id}` : null,
|
|
65
75
|
symptomTerm: symptomTermOf(resource),
|
|
76
|
+
turnId: turnIdOf(resource),
|
|
66
77
|
};
|
|
67
78
|
};
|
|
68
79
|
|
|
@@ -89,6 +100,14 @@ function symptomTermOf(resource) {
|
|
|
89
100
|
return null;
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
function turnIdOf(resource) {
|
|
104
|
+
for (const ext of resource.extension || []) {
|
|
105
|
+
const url = (ext || {}).url;
|
|
106
|
+
if (typeof url === 'string' && url.endsWith(TURN_ID_EXT_SUFFIX)) return ext.valueString || null;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
function businessIdentifier(resource) {
|
|
93
112
|
for (const identifier of resource.identifier || []) {
|
|
94
113
|
const system = (identifier || {}).system;
|
|
@@ -1,24 +1,123 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const { Provenance } = require('../resources/Provenance');
|
|
1
|
+
const { ZodError } = require('zod');
|
|
3
2
|
|
|
4
|
-
const
|
|
3
|
+
const { codeSystemUrl } = require('../helpers/fhirHelper');
|
|
4
|
+
const { identifier, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
5
|
+
const { baseResource, provenance } = require('../helpers/resourceHelper');
|
|
6
|
+
const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
|
|
7
|
+
const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG } = require('../constants/projectionSlugs');
|
|
8
|
+
const { CLUSTER_EXT } = require('../constants/extensionSlugs');
|
|
9
|
+
const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
|
|
10
|
+
const { logger } = require('../../utils/logger');
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
const LIST_SEPARATOR = '; ';
|
|
13
|
+
const STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
|
|
14
|
+
const ACTIVITY_CODESYSTEM = 'clinical-activity';
|
|
15
|
+
const ACTIVITY_CODES = {
|
|
16
|
+
opened: 'cluster-hypothesis-opened',
|
|
17
|
+
updated: 'cluster-hypothesis-updated',
|
|
18
|
+
dissolved: 'cluster-hypothesis-dissolved',
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_ACTIVITY_CODE = 'cluster-hypothesis';
|
|
21
|
+
|
|
22
|
+
function toFhir({ cluster, history = [] }) {
|
|
8
23
|
const records = history.length
|
|
9
24
|
? [...history].sort((a, b) => Date.parse(a.recordedAt) - Date.parse(b.recordedAt))
|
|
10
25
|
: [{ turnId: 'opened', action: 'opened', recordedAt: cluster.openedAt, transitionReason: null }];
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
return [clusterResource(cluster), ...records.map((record) => historyProvenance(cluster.clusterId, record))];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clusterResource(cluster) {
|
|
30
|
+
const payload = {
|
|
31
|
+
...baseResource('ClinicalImpression', cluster.clusterId, CLUSTER_IMPRESSION_SLUG, cluster.patientId),
|
|
32
|
+
status: STATUS_TO_FHIR[cluster.status],
|
|
33
|
+
problem: cluster.memberCaseIds.map((caseId) => reference('Condition', caseId)),
|
|
34
|
+
extension: clusterExtensions(cluster),
|
|
35
|
+
};
|
|
36
|
+
if (cluster.label) payload.identifier.push(identifier(CLUSTER_LABEL_SLUG, cluster.label));
|
|
37
|
+
if (cluster.relationshipModel) payload.summary = cluster.relationshipModel;
|
|
38
|
+
if (cluster.openingReasoning) payload.description = cluster.openingReasoning;
|
|
39
|
+
if (cluster.dissolvedAt) payload.effectivePeriod = { start: toIso(cluster.openedAt), end: toIso(cluster.dissolvedAt) };
|
|
40
|
+
else payload.effectiveDateTime = toIso(cluster.openedAt);
|
|
41
|
+
if (cluster.dissolveCategory) payload.statusReason = codeableConcept(cluster.dissolveCategory);
|
|
42
|
+
return payload;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clusterExtensions(cluster) {
|
|
46
|
+
const out = [
|
|
47
|
+
extension(CLUSTER_EXT.HYPOTHESIS_TYPE, { valueString: cluster.hypothesisType }),
|
|
48
|
+
extension(CLUSTER_EXT.CONFIDENCE, { valueString: cluster.confidence }),
|
|
49
|
+
extension(CLUSTER_EXT.CLINICAL_SIGNIFICANCE, { valueString: cluster.clinicalSignificance }),
|
|
50
|
+
];
|
|
51
|
+
if (cluster.strengthensIf.length) {
|
|
52
|
+
out.push(extension(CLUSTER_EXT.STRENGTHENS_IF, { valueString: cluster.strengthensIf.join(LIST_SEPARATOR) }));
|
|
53
|
+
}
|
|
54
|
+
if (cluster.weakensIf.length) {
|
|
55
|
+
out.push(extension(CLUSTER_EXT.WEAKENS_IF, { valueString: cluster.weakensIf.join(LIST_SEPARATOR) }));
|
|
56
|
+
}
|
|
57
|
+
out.push(extension(CLUSTER_EXT.STATUS, { valueString: cluster.status }));
|
|
58
|
+
if (cluster.consultationId) out.push(extension(CLUSTER_EXT.CONSULTATION_ID, { valueString: cluster.consultationId }));
|
|
59
|
+
if (cluster.dissolveReason) out.push(extension(CLUSTER_EXT.DISSOLVE_REASON, { valueString: cluster.dissolveReason }));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function historyProvenance(clusterId, record) {
|
|
64
|
+
const code = ACTIVITY_CODES[record.action] || DEFAULT_ACTIVITY_CODE;
|
|
65
|
+
const payload = provenance({
|
|
66
|
+
provenanceId: `${clusterId}-prov-${record.turnId}-${record.action}`,
|
|
67
|
+
targetRefs: [reference('ClinicalImpression', clusterId)],
|
|
15
68
|
recordedAt: record.recordedAt,
|
|
16
|
-
|
|
17
|
-
})
|
|
18
|
-
|
|
69
|
+
activity: codeableConcept(code, { code, system: codeSystemUrl(ACTIVITY_CODESYSTEM) }),
|
|
70
|
+
});
|
|
71
|
+
if (record.transitionReason) {
|
|
72
|
+
payload.extension = [extension('transition-reason', { valueString: record.transitionReason })];
|
|
73
|
+
}
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fromFhir(resources, { patientId }) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const resource of resources) {
|
|
80
|
+
if (resource.resourceType !== 'ClinicalImpression') continue;
|
|
81
|
+
if (!identifierValue(resource, CLUSTER_IMPRESSION_SLUG)) continue;
|
|
82
|
+
try {
|
|
83
|
+
out.push(new ClusterImpression(reconstruct(resource, patientId)));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof ZodError) {
|
|
86
|
+
logger.warn({ clusterId: resource.id }, 'skipping malformed cluster ClinicalImpression');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function reconstruct(resource, patientId) {
|
|
96
|
+
const ext = extMap(resource);
|
|
97
|
+
const splitList = (value) => (value ? value.split(LIST_SEPARATOR) : []);
|
|
98
|
+
return {
|
|
99
|
+
schemaVersion: '1',
|
|
100
|
+
clusterId: identifierValue(resource, CLUSTER_IMPRESSION_SLUG),
|
|
101
|
+
patientId,
|
|
102
|
+
label: identifierValue(resource, CLUSTER_LABEL_SLUG) || '',
|
|
103
|
+
hypothesisType: ext[CLUSTER_EXT.HYPOTHESIS_TYPE],
|
|
104
|
+
status: ext[CLUSTER_EXT.STATUS],
|
|
105
|
+
confidence: ext[CLUSTER_EXT.CONFIDENCE],
|
|
106
|
+
clinicalSignificance: ext[CLUSTER_EXT.CLINICAL_SIGNIFICANCE],
|
|
107
|
+
memberCaseIds: (resource.problem || []).map((problemRef) => refToId(problemRef.reference)).filter(Boolean),
|
|
108
|
+
consultationId: ext[CLUSTER_EXT.CONSULTATION_ID] || null,
|
|
109
|
+
relationshipModel: resource.summary || '',
|
|
110
|
+
strengthensIf: splitList(ext[CLUSTER_EXT.STRENGTHENS_IF]),
|
|
111
|
+
weakensIf: splitList(ext[CLUSTER_EXT.WEAKENS_IF]),
|
|
112
|
+
openingReasoning: resource.description || '',
|
|
113
|
+
openedAt: resource.effectiveDateTime || resource.effectivePeriod?.start,
|
|
114
|
+
dissolvedAt: resource.effectivePeriod?.end || null,
|
|
115
|
+
dissolveReason: ext[CLUSTER_EXT.DISSOLVE_REASON] || null,
|
|
116
|
+
dissolveCategory: resource.statusReason?.text || null,
|
|
117
|
+
};
|
|
19
118
|
}
|
|
20
119
|
|
|
21
120
|
module.exports = {
|
|
22
|
-
|
|
23
|
-
|
|
121
|
+
toFhir,
|
|
122
|
+
fromFhir,
|
|
24
123
|
};
|
|
@@ -1,42 +1,244 @@
|
|
|
1
|
-
const {
|
|
2
|
-
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
const
|
|
1
|
+
const { ZodError } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { getDeviceId } = require('../config/fhirConfig');
|
|
4
|
+
const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
|
|
5
|
+
const { identifier, patientReference, reference, recipientReference, codeableConcept, extension, notes, toIso } = require('../helpers/elementHelper');
|
|
6
|
+
const { baseResource, provenance } = require('../helpers/resourceHelper');
|
|
7
|
+
const { identifierValue, refToId, extValues } = require('../helpers/fhirReadHelper');
|
|
8
|
+
const { INTERVENTION_SLUG, INTERVENTION_KIND_SLUG, INTERVENTION_OUTCOME_SLUG } = require('../constants/projectionSlugs');
|
|
9
|
+
const { Intervention } = require('../../shared/dtos/Intervention');
|
|
10
|
+
const { logger } = require('../../utils/logger');
|
|
11
|
+
|
|
9
12
|
const ACTIVITY_TEXT = 'intervention-record';
|
|
10
13
|
const PROCEDURE_KINDS = new Set(['otc', 'self_care', 'other']);
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
const EXT = {
|
|
16
|
+
RATIONALE: 'intervention-rationale',
|
|
17
|
+
OUTCOME_NOTES: 'intervention-outcome-notes',
|
|
18
|
+
OUTCOME: 'intervention-outcome',
|
|
19
|
+
RECIPIENT_KIND: 'intervention-recipient-kind',
|
|
20
|
+
RECIPIENT_REF: 'intervention-recipient-ref',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const RESOURCE_TYPES = ['Procedure', 'Communication', 'Task', 'CommunicationRequest'];
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
const interventionReason = (caseId) => [{ reference: reference('Condition', caseId) }];
|
|
26
|
+
const withNote = (payload, intervention) => {
|
|
27
|
+
const note = notes(intervention.rationale, intervention.outcomeNotes);
|
|
28
|
+
return note ? { ...payload, note } : payload;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function toFhir(intervention) {
|
|
32
|
+
const primary = primaryResource(intervention);
|
|
33
|
+
return [primary, provenance({
|
|
16
34
|
provenanceId: `${intervention.interventionId}-prov`,
|
|
17
35
|
targetRefs: [{ reference: `${primary.resourceType}/${fhirId(intervention.interventionId)}` }],
|
|
18
36
|
recordedAt: intervention.deliveredAt,
|
|
19
|
-
|
|
37
|
+
activity: codeableConcept(ACTIVITY_TEXT),
|
|
20
38
|
})];
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
function primaryResource(intervention) {
|
|
24
|
-
if (PROCEDURE_KINDS.has(intervention.kind))
|
|
25
|
-
|
|
42
|
+
if (PROCEDURE_KINDS.has(intervention.kind)) return procedureResource(intervention);
|
|
43
|
+
if (intervention.kind === 'education') return communicationResource(intervention);
|
|
44
|
+
if (intervention.kind === 'team_escalation') return taskResource(intervention);
|
|
45
|
+
if (intervention.kind === 'follow_up_check') return communicationRequestResource(intervention);
|
|
46
|
+
throw new Error(`unknown intervention variant: ${intervention.kind}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function procedureResource(intervention) {
|
|
50
|
+
return withNote({
|
|
51
|
+
...baseResource('Procedure', intervention.interventionId, INTERVENTION_SLUG, intervention.patientId),
|
|
52
|
+
status: intervention.outcome === 'pending' ? 'in-progress' : 'completed',
|
|
53
|
+
code: codeableConcept(intervention.interventionLabel),
|
|
54
|
+
reason: interventionReason(intervention.caseId),
|
|
55
|
+
occurrenceDateTime: toIso(intervention.deliveredAt),
|
|
56
|
+
outcome: codeableConcept(intervention.outcome, { code: intervention.outcome, system: codeSystemUrl(INTERVENTION_OUTCOME_SLUG) }),
|
|
57
|
+
category: [codeableConcept(intervention.kind, { code: intervention.kind, system: codeSystemUrl(INTERVENTION_KIND_SLUG) })],
|
|
58
|
+
extension: interventionExtensions(intervention, { withRecipient: true }),
|
|
59
|
+
}, intervention);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function communicationResource(intervention) {
|
|
63
|
+
return withNote({
|
|
64
|
+
...baseResource('Communication', intervention.interventionId, INTERVENTION_SLUG, intervention.patientId),
|
|
65
|
+
status: 'completed',
|
|
66
|
+
category: [codeableConcept(intervention.kind, { code: intervention.kind, system: codeSystemUrl(INTERVENTION_KIND_SLUG) })],
|
|
67
|
+
reason: interventionReason(intervention.caseId),
|
|
68
|
+
sent: toIso(intervention.deliveredAt),
|
|
69
|
+
payload: [{ contentCodeableConcept: codeableConcept(intervention.interventionLabel) }],
|
|
70
|
+
recipient: [recipientReference({
|
|
71
|
+
patientId: intervention.patientId,
|
|
72
|
+
recipientKind: intervention.recipientKind,
|
|
73
|
+
recipientRefId: intervention.recipientRefId,
|
|
74
|
+
})],
|
|
75
|
+
extension: interventionExtensions(intervention),
|
|
76
|
+
}, intervention);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function taskResource(intervention) {
|
|
80
|
+
const payload = withNote({
|
|
81
|
+
resourceType: 'Task',
|
|
82
|
+
id: fhirId(intervention.interventionId),
|
|
83
|
+
identifier: [identifier(INTERVENTION_SLUG, intervention.interventionId)],
|
|
84
|
+
status: intervention.outcome === 'pending' ? 'requested' : 'completed',
|
|
85
|
+
intent: 'order',
|
|
86
|
+
code: codeableConcept(intervention.interventionLabel, {
|
|
87
|
+
code: intervention.kind,
|
|
88
|
+
system: codeSystemUrl(INTERVENTION_KIND_SLUG),
|
|
89
|
+
display: intervention.interventionLabel,
|
|
90
|
+
}),
|
|
91
|
+
authoredOn: toIso(intervention.deliveredAt),
|
|
92
|
+
for: patientReference(intervention.patientId),
|
|
93
|
+
requester: reference('Device', getDeviceId()),
|
|
94
|
+
reason: interventionReason(intervention.caseId),
|
|
95
|
+
extension: interventionExtensions(intervention, { withRecipient: true }),
|
|
96
|
+
}, intervention);
|
|
97
|
+
if (intervention.outcome !== 'pending') {
|
|
98
|
+
const outcomeCc = codeableConcept(intervention.outcome, { code: intervention.outcome, system: codeSystemUrl(INTERVENTION_OUTCOME_SLUG) });
|
|
99
|
+
payload.output = [{ type: codeableConcept(INTERVENTION_OUTCOME_SLUG), valueCodeableConcept: outcomeCc }];
|
|
26
100
|
}
|
|
27
|
-
|
|
28
|
-
|
|
101
|
+
return payload;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function communicationRequestResource(intervention) {
|
|
105
|
+
return withNote({
|
|
106
|
+
...baseResource('CommunicationRequest', intervention.interventionId, INTERVENTION_SLUG, intervention.patientId),
|
|
107
|
+
status: intervention.outcome === 'pending' ? 'active' : 'completed',
|
|
108
|
+
intent: 'plan',
|
|
109
|
+
category: [codeableConcept(intervention.kind, { code: intervention.kind, system: codeSystemUrl(INTERVENTION_KIND_SLUG) })],
|
|
110
|
+
reason: interventionReason(intervention.caseId),
|
|
111
|
+
payload: [{ contentCodeableConcept: codeableConcept(intervention.interventionLabel) }],
|
|
112
|
+
recipient: [recipientReference({
|
|
113
|
+
patientId: intervention.patientId,
|
|
114
|
+
recipientKind: intervention.recipientKind,
|
|
115
|
+
recipientRefId: intervention.recipientRefId,
|
|
116
|
+
})],
|
|
117
|
+
occurrenceDateTime: toIso(intervention.deliveredAt),
|
|
118
|
+
authoredOn: toIso(intervention.deliveredAt),
|
|
119
|
+
extension: interventionExtensions(intervention),
|
|
120
|
+
}, intervention);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function interventionExtensions(intervention, { withRecipient = false } = {}) {
|
|
124
|
+
const out = [];
|
|
125
|
+
if (intervention.rationale) out.push(extension(EXT.RATIONALE, { valueString: intervention.rationale }));
|
|
126
|
+
if (intervention.outcomeNotes) out.push(extension(EXT.OUTCOME_NOTES, { valueString: intervention.outcomeNotes }));
|
|
127
|
+
if (intervention.outcome) out.push(extension(EXT.OUTCOME, { valueString: intervention.outcome }));
|
|
128
|
+
if (withRecipient && intervention.recipientKind === 'related_person' && intervention.recipientRefId) {
|
|
129
|
+
out.push(extension(EXT.RECIPIENT_KIND, { valueString: 'related_person' }));
|
|
130
|
+
out.push(extension(EXT.RECIPIENT_REF, { valueString: intervention.recipientRefId }));
|
|
29
131
|
}
|
|
30
|
-
|
|
31
|
-
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fromFhir(resources, { patientId }) {
|
|
136
|
+
return resources
|
|
137
|
+
.filter((r) => RESOURCE_TYPES.includes(r.resourceType) && identifierValue(r, INTERVENTION_SLUG))
|
|
138
|
+
.map((resource) => reconstruct(resource, patientId))
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reconstruct(resource, patientId) {
|
|
143
|
+
const reasonRef = resource.reason && resource.reason[0] && resource.reason[0].reference;
|
|
144
|
+
const base = {
|
|
145
|
+
interventionId: identifierValue(resource, INTERVENTION_SLUG),
|
|
146
|
+
patientId,
|
|
147
|
+
caseId: refToId(reasonRef && reasonRef.reference),
|
|
148
|
+
rationale: extValues(resource, EXT.RATIONALE)[0] ?? null,
|
|
149
|
+
outcomeNotes: extValues(resource, EXT.OUTCOME_NOTES)[0] ?? null,
|
|
150
|
+
outcomeExt: extValues(resource, EXT.OUTCOME)[0] ?? null,
|
|
151
|
+
recipientKind: 'patient',
|
|
152
|
+
recipientRefId: null,
|
|
153
|
+
};
|
|
154
|
+
let reconstructed;
|
|
155
|
+
if (resource.resourceType === 'Procedure') reconstructed = reconstructProcedure(resource, base);
|
|
156
|
+
else if (resource.resourceType === 'Communication') reconstructed = reconstructCommunication(resource, base);
|
|
157
|
+
else if (resource.resourceType === 'Task') reconstructed = reconstructTask(resource, base);
|
|
158
|
+
else reconstructed = reconstructCommunicationRequest(resource, base);
|
|
159
|
+
try {
|
|
160
|
+
return new Intervention(reconstructed);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof ZodError) {
|
|
163
|
+
logger.warn({ interventionId: reconstructed.interventionId }, 'skipping malformed intervention resource');
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
32
167
|
}
|
|
33
|
-
|
|
34
|
-
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reconstructProcedure(resource, base) {
|
|
171
|
+
const { outcomeExt, ...dto } = base;
|
|
172
|
+
return {
|
|
173
|
+
...dto,
|
|
174
|
+
kind: categoryCode(resource),
|
|
175
|
+
interventionLabel: (resource.code && resource.code.text) || null,
|
|
176
|
+
deliveredAt: resource.occurrenceDateTime,
|
|
177
|
+
outcome: outcomeExt ?? codeableConceptCode(resource.outcome),
|
|
178
|
+
...recipientFromExtension(resource),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function reconstructCommunication(resource, base) {
|
|
183
|
+
const { outcomeExt, ...dto } = base;
|
|
184
|
+
const content = resource.payload && resource.payload[0] && resource.payload[0].contentCodeableConcept;
|
|
185
|
+
const outcome = outcomeExt ?? undefined;
|
|
186
|
+
return {
|
|
187
|
+
...dto,
|
|
188
|
+
kind: categoryCode(resource),
|
|
189
|
+
interventionLabel: (content && content.text) || null,
|
|
190
|
+
deliveredAt: resource.sent,
|
|
191
|
+
...(outcome ? { outcome } : {}),
|
|
192
|
+
...recipientFromReference(resource),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reconstructTask(resource, base) {
|
|
197
|
+
const { outcomeExt, ...dto } = base;
|
|
198
|
+
const output = resource.output && resource.output[0];
|
|
199
|
+
return {
|
|
200
|
+
...dto,
|
|
201
|
+
kind: codingCode(resource.code),
|
|
202
|
+
interventionLabel: (resource.code && resource.code.text) || null,
|
|
203
|
+
deliveredAt: resource.authoredOn,
|
|
204
|
+
outcome: outcomeExt ?? (output ? codeableConceptCode(output.valueCodeableConcept) : 'pending'),
|
|
205
|
+
...recipientFromExtension(resource),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reconstructCommunicationRequest(resource, base) {
|
|
210
|
+
const { outcomeExt, ...dto } = base;
|
|
211
|
+
const content = resource.payload && resource.payload[0] && resource.payload[0].contentCodeableConcept;
|
|
212
|
+
return {
|
|
213
|
+
...dto,
|
|
214
|
+
kind: categoryCode(resource),
|
|
215
|
+
interventionLabel: (content && content.text) || null,
|
|
216
|
+
deliveredAt: resource.occurrenceDateTime,
|
|
217
|
+
outcome: outcomeExt ?? (resource.status === 'active' ? 'pending' : 'unknown'),
|
|
218
|
+
...recipientFromReference(resource),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function recipientFromExtension(resource) {
|
|
223
|
+
const kind = extValues(resource, EXT.RECIPIENT_KIND)[0];
|
|
224
|
+
const refId = extValues(resource, EXT.RECIPIENT_REF)[0];
|
|
225
|
+
if (kind === 'related_person' && refId) return { recipientKind: 'related_person', recipientRefId: refId };
|
|
226
|
+
return { recipientKind: 'patient', recipientRefId: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function recipientFromReference(resource) {
|
|
230
|
+
const recipientRef = resource.recipient && resource.recipient[0] && resource.recipient[0].reference;
|
|
231
|
+
if (recipientRef && recipientRef.startsWith('RelatedPerson/')) {
|
|
232
|
+
return { recipientKind: 'related_person', recipientRefId: refToId(recipientRef) };
|
|
35
233
|
}
|
|
36
|
-
|
|
234
|
+
return { recipientKind: 'patient', recipientRefId: null };
|
|
37
235
|
}
|
|
38
236
|
|
|
237
|
+
const codingCode = (concept) => (concept && concept.coding && concept.coding[0] && concept.coding[0].code) || null;
|
|
238
|
+
const categoryCode = (resource) => codingCode(resource.category && resource.category[0]);
|
|
239
|
+
const codeableConceptCode = (concept) => codingCode(concept) || (concept && concept.text) || 'pending';
|
|
240
|
+
|
|
39
241
|
module.exports = {
|
|
40
|
-
|
|
41
|
-
|
|
242
|
+
toFhir,
|
|
243
|
+
fromFhir,
|
|
42
244
|
};
|
|
@@ -2,13 +2,9 @@ const { registerProjector } = require('../services/fhirService');
|
|
|
2
2
|
const { projectClinicalMention, PROJECTOR_NAME: MENTION_PROJECTOR_NAME } = require('./clinicalMentionProjection');
|
|
3
3
|
const { projectDispatchedEscalation, PROJECTOR_NAME: ESCALATION_PROJECTOR_NAME } = require('./dispatchedEscalationProjection');
|
|
4
4
|
const { projectReminder, PROJECTOR_NAME: REMINDER_PROJECTOR_NAME } = require('./reminderProjection');
|
|
5
|
-
const { projectSymptomCase, PROJECTOR_NAME: SYMPTOM_CASE_PROJECTOR_NAME } = require('./symptomCaseProjection');
|
|
6
5
|
const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
|
|
7
6
|
const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
|
|
8
7
|
const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
|
|
9
|
-
const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
|
|
10
|
-
const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
|
|
11
|
-
const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
|
|
12
8
|
const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
|
|
13
9
|
const { projectPalliativeCarePlan, PROJECTOR_NAME: PALLIATIVE_CAREPLAN_PROJECTOR_NAME } = require('./palliativeCarePlanProjection');
|
|
14
10
|
|
|
@@ -16,13 +12,9 @@ const PROJECTORS = [
|
|
|
16
12
|
{ name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
|
|
17
13
|
{ name: ESCALATION_PROJECTOR_NAME, project: projectDispatchedEscalation },
|
|
18
14
|
{ name: REMINDER_PROJECTOR_NAME, project: projectReminder },
|
|
19
|
-
{ name: SYMPTOM_CASE_PROJECTOR_NAME, project: projectSymptomCase },
|
|
20
15
|
{ name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
|
|
21
16
|
{ name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
|
|
22
17
|
{ name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
|
|
23
|
-
{ name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
|
|
24
|
-
{ name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
|
|
25
|
-
{ name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
|
|
26
18
|
{ name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
|
|
27
19
|
{ name: PALLIATIVE_CAREPLAN_PROJECTOR_NAME, project: projectPalliativeCarePlan },
|
|
28
20
|
];
|