@peopl-health/nexus 5.13.0-dev.1149 → 5.13.0-dev.1151

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.
@@ -1,6 +1,6 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
- const { storeRoutingDecision, readRoutingByTurn } = require('../../fhir');
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 readRoutingByTurn({ patientId, turnId });
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
  }
@@ -11,13 +11,16 @@ function baseResource(resourceType, id, slug, patientId) {
11
11
  };
12
12
  }
13
13
 
14
- function provenance({ provenanceId, targetRefs, recordedAt, activityText }) {
14
+ function provenance({ provenanceId, targetRefs, recordedAt, activityText, 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: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
20
+ agent: [{
21
+ who: reference('Device', agentDevice || getDeviceId()),
22
+ onBehalfOf: reference('Organization', onBehalfOf || getOrganizationId()),
23
+ }],
21
24
  activity: codeableConcept(activityText),
22
25
  };
23
26
  }
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, readRoutingByTurn } = require('./services/routingService');
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
- readRoutingByTurn,
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: {
@@ -27,6 +29,10 @@ envelopeSchema.index(
27
29
  { 'index.patient': 1, 'resource.identifier.system': 1, 'index.date': -1 },
28
30
  { partialFilterExpression: { 'meta.deleted': false } },
29
31
  );
32
+ envelopeSchema.index(
33
+ { 'index.patient': 1, 'index.turnId': 1, 'index.date': -1 },
34
+ { partialFilterExpression: { 'meta.deleted': false, 'index.turnId': { $type: 'string' } } },
35
+ );
30
36
  envelopeSchema.index({ 'index.reference': 1 }, { partialFilterExpression: { 'index.reference': { $type: 'string' } } });
31
37
  envelopeSchema.index({ 'resource.status': 1 }, { sparse: true });
32
38
 
@@ -67,6 +73,7 @@ envelopeSchema.statics.deriveIndex = function deriveIndex(resource) {
67
73
  date: instantOf(date),
68
74
  reference: resource.resourceType && resource.id ? `${resource.resourceType}/${resource.id}` : null,
69
75
  symptomTerm: symptomTermOf(resource),
76
+ turnId: turnIdOf(resource),
70
77
  };
71
78
  };
72
79
 
@@ -93,6 +100,14 @@ function symptomTermOf(resource) {
93
100
  return null;
94
101
  }
95
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
+
96
111
  function businessIdentifier(resource) {
97
112
  for (const identifier of resource.identifier || []) {
98
113
  const system = (identifier || {}).system;
@@ -6,7 +6,6 @@ const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./r
6
6
  const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
7
7
  const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
8
8
  const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
9
- const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
10
9
  const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
11
10
  const { projectPalliativeCarePlan, PROJECTOR_NAME: PALLIATIVE_CAREPLAN_PROJECTOR_NAME } = require('./palliativeCarePlanProjection');
12
11
 
@@ -18,7 +17,6 @@ const PROJECTORS = [
18
17
  { name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
19
18
  { name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
20
19
  { name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
21
- { name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
22
20
  { name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
23
21
  { name: PALLIATIVE_CAREPLAN_PROJECTOR_NAME, project: projectPalliativeCarePlan },
24
22
  ];
@@ -1,48 +1,284 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const { ClinicalImpression } = require('../resources/ClinicalImpression');
3
- const { Communication } = require('../resources/Communication');
4
- const { CommunicationRequest } = require('../resources/CommunicationRequest');
5
- const { Task } = require('../resources/Task');
6
- const { RequestOrchestration } = require('../resources/RequestOrchestration');
7
- const { CarePlan } = require('../resources/CarePlan');
8
- const { Provenance } = require('../resources/Provenance');
9
-
10
- const PROJECTOR_NAME = 'routing';
1
+ const { ZodError } = require('zod');
2
+
3
+ const { getDeviceId } = require('../config/fhirConfig');
4
+ const { reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
5
+ const { baseResource, provenance } = require('../helpers/resourceHelper');
6
+ const { extMap, extValues, identifierValue, refToId } = require('../helpers/fhirReadHelper');
7
+ const { ROUTING_ASSESSMENT_SLUG, ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
8
+ const { ROUTING_EXT } = require('../constants/extensionSlugs');
9
+ const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
10
+ const { logger } = require('../../utils/logger');
11
+
11
12
  const ACTIVITY_TEXT = 'routing-decision';
13
+ const COMPOSE_AS_PREFIX = 'compose_as: ';
14
+
15
+ const ccText = (concept) => concept?.text || null;
16
+ const caseReason = (caseId) => [{ reference: reference('Condition', caseId) }];
17
+
18
+ function dispositionBase(resourceType, disposition) {
19
+ return baseResource(resourceType, disposition.dispositionId, ROUTING_DISPOSITION_SLUG, disposition.patientId);
20
+ }
12
21
 
13
- const DISPOSITION_BY_KIND = {
14
- communication: Communication,
15
- communication_request: CommunicationRequest,
16
- task: Task,
17
- orchestration: RequestOrchestration,
18
- care_plan: CarePlan,
22
+ const DISPOSITION_CODECS = {
23
+ communication: {
24
+ resourceType: 'Communication',
25
+ build: (d) => ({
26
+ ...dispositionBase('Communication', d),
27
+ status: 'completed',
28
+ category: [codeableConcept(d.primaryAction)],
29
+ sent: toIso(d.authoredAt),
30
+ payload: [{ contentCodeableConcept: codeableConcept(d.primaryReason) }],
31
+ recipient: [patientReference(d.patientId)],
32
+ ...(d.primaryCaseId ? { reason: caseReason(d.primaryCaseId) } : {}),
33
+ }),
34
+ actionOf: (r) => ccText(r.category?.[0]),
35
+ reasonOf: (r) => ccText(r.payload?.[0]?.contentCodeableConcept),
36
+ authoredOf: (r) => r.sent,
37
+ },
38
+ communication_request: {
39
+ resourceType: 'CommunicationRequest',
40
+ build: (d) => ({
41
+ ...dispositionBase('CommunicationRequest', d),
42
+ status: 'active',
43
+ intent: 'plan',
44
+ category: [codeableConcept(d.primaryAction)],
45
+ authoredOn: toIso(d.authoredAt),
46
+ payload: [{ contentCodeableConcept: codeableConcept(d.primaryReason) }],
47
+ recipient: [patientReference(d.patientId)],
48
+ ...(d.primaryCaseId ? { reason: caseReason(d.primaryCaseId) } : {}),
49
+ }),
50
+ actionOf: (r) => ccText(r.category?.[0]),
51
+ reasonOf: (r) => ccText(r.payload?.[0]?.contentCodeableConcept),
52
+ authoredOf: (r) => r.authoredOn,
53
+ },
54
+ task: {
55
+ resourceType: 'Task',
56
+ build: (d) => {
57
+ const { subject, ...rest } = dispositionBase('Task', d);
58
+ return {
59
+ ...rest,
60
+ status: 'requested',
61
+ intent: 'order',
62
+ code: codeableConcept(d.primaryAction),
63
+ description: d.primaryReason,
64
+ for: subject,
65
+ authoredOn: toIso(d.authoredAt),
66
+ requester: reference('Device', getDeviceId()),
67
+ ...(d.primaryCaseId ? { reason: caseReason(d.primaryCaseId) } : {}),
68
+ };
69
+ },
70
+ actionOf: (r) => ccText(r.code),
71
+ reasonOf: (r) => r.description,
72
+ authoredOf: (r) => r.authoredOn,
73
+ },
74
+ orchestration: {
75
+ resourceType: 'RequestOrchestration',
76
+ build: (d) => ({
77
+ ...dispositionBase('RequestOrchestration', d),
78
+ status: 'active',
79
+ intent: 'plan',
80
+ authoredOn: toIso(d.authoredAt),
81
+ action: [
82
+ { title: d.primaryAction, description: d.primaryReason, code: [codeableConcept(d.primaryAction)] },
83
+ ...d.secondaryActions.map((secondary) => ({
84
+ title: secondary.action,
85
+ description: `${COMPOSE_AS_PREFIX}${secondary.composeAs}`,
86
+ code: [codeableConcept(secondary.action)],
87
+ })),
88
+ ],
89
+ ...(d.primaryCaseId ? { reason: caseReason(d.primaryCaseId) } : {}),
90
+ }),
91
+ actionOf: (r) => r.action?.[0]?.title,
92
+ reasonOf: (r) => r.action?.[0]?.description,
93
+ authoredOf: (r) => r.authoredOn,
94
+ },
95
+ care_plan: {
96
+ resourceType: 'CarePlan',
97
+ build: (d) => ({
98
+ ...dispositionBase('CarePlan', d),
99
+ status: 'active',
100
+ intent: 'plan',
101
+ category: [codeableConcept(d.primaryAction)],
102
+ created: toIso(d.authoredAt),
103
+ description: d.primaryReason,
104
+ ...(d.primaryCaseId ? { addresses: caseReason(d.primaryCaseId) } : {}),
105
+ }),
106
+ actionOf: (r) => ccText(r.category?.[0]),
107
+ reasonOf: (r) => r.description,
108
+ authoredOf: (r) => r.created,
109
+ },
19
110
  };
20
111
 
21
- function projectRoutingDecision(routingDecision) {
22
- const { decisionId, committedAt, assessment, disposition, provenance } = routingDecision;
23
- const impression = ClinicalImpression.fromRoutingAssessment({
24
- assessment,
25
- decisionId,
26
- committedAt,
27
- deferredItems: disposition.deferredItems,
28
- primaryCaseId: disposition.primaryCaseId,
29
- });
30
- const resourceClass = DISPOSITION_BY_KIND[disposition.kind];
31
- if (!resourceClass) throw new Error(`unknown routing-disposition kind: ${disposition.kind}`);
32
- const dispositionResource = resourceClass.fromRoutingDisposition({ disposition });
33
- const provenanceResource = Provenance.fromRoutingDecision({
34
- provenanceId: `${assessment.assessmentId}-prov`,
35
- targetRefs: [
36
- { reference: `ClinicalImpression/${fhirId(assessment.assessmentId)}` },
37
- { reference: `${dispositionResource.resourceType}/${fhirId(disposition.dispositionId)}` },
38
- ],
39
- provenance,
40
- activityText: ACTIVITY_TEXT,
41
- });
42
- return [impression, dispositionResource, provenanceResource];
112
+ const KIND_BY_RESOURCE_TYPE = Object.fromEntries(
113
+ Object.entries(DISPOSITION_CODECS).map(([kind, codec]) => [codec.resourceType, kind]),
114
+ );
115
+
116
+ function toFhir(routingDecision) {
117
+ const { decisionId, committedAt, assessment, disposition, provenance: prov } = routingDecision;
118
+ const codec = DISPOSITION_CODECS[disposition.kind];
119
+ if (!codec) throw new Error(`unknown routing-disposition kind: ${disposition.kind}`);
120
+ const dispositionResource = codec.build(disposition);
121
+ return [
122
+ assessmentResource(assessment, { decisionId, committedAt, disposition }),
123
+ dispositionResource,
124
+ provenance({
125
+ provenanceId: `${assessment.assessmentId}-prov`,
126
+ targetRefs: [
127
+ reference('ClinicalImpression', assessment.assessmentId),
128
+ reference(dispositionResource.resourceType, disposition.dispositionId),
129
+ ],
130
+ recordedAt: prov.recordedAt,
131
+ activityText: ACTIVITY_TEXT,
132
+ agentDevice: prov.agentDevice,
133
+ onBehalfOf: prov.onBehalfOf,
134
+ }),
135
+ ];
136
+ }
137
+
138
+ function assessmentResource(assessment, { decisionId, committedAt, disposition }) {
139
+ const payload = {
140
+ ...baseResource('ClinicalImpression', assessment.assessmentId, ROUTING_ASSESSMENT_SLUG, assessment.patientId),
141
+ status: 'completed',
142
+ effectiveDateTime: toIso(assessment.performedAt),
143
+ finding: assessment.findings.map((finding) => {
144
+ const entry = {};
145
+ const extensions = [];
146
+ if (finding.caseId) {
147
+ entry.item = reference('Condition', finding.caseId);
148
+ extensions.push(extension(ROUTING_EXT.FINDING_CASE_ID, { valueString: finding.caseId }));
149
+ }
150
+ extensions.push(
151
+ extension(ROUTING_EXT.FINDING_REASONING, { valueString: finding.clinicalReasoning }),
152
+ extension(ROUTING_EXT.FINDING_ACTION, { valueString: finding.decisionAction }),
153
+ );
154
+ entry.extension = extensions;
155
+ return entry;
156
+ }),
157
+ extension: assessmentExtensions(assessment, { decisionId, committedAt, disposition }),
158
+ };
159
+ if (assessment.summary) payload.summary = assessment.summary;
160
+ if (assessment.previousId) payload.previous = reference('ClinicalImpression', assessment.previousId);
161
+ if (assessment.notes.length) payload.note = assessment.notes.map((text) => ({ text }));
162
+ return payload;
163
+ }
164
+
165
+ function assessmentExtensions(assessment, { decisionId, committedAt, disposition }) {
166
+ const out = [
167
+ extension(ROUTING_EXT.TURN_ID, { valueString: assessment.turnId }),
168
+ extension(ROUTING_EXT.DECISION_ID, { valueString: decisionId }),
169
+ extension(ROUTING_EXT.COMMITTED_AT, { valueDateTime: toIso(committedAt) }),
170
+ ];
171
+ if (disposition.primaryCaseId) {
172
+ out.push(extension(ROUTING_EXT.PRIMARY_CASE_ID, { valueString: disposition.primaryCaseId }));
173
+ }
174
+ for (const deferred of disposition.deferredItems) {
175
+ out.push(
176
+ extension(ROUTING_EXT.DEFERRED_ITEM, { valueString: deferred.item }),
177
+ extension(ROUTING_EXT.DEFERRED_REASON, { valueString: deferred.deferReason }),
178
+ );
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function fromFhir(resources, { patientId }) {
184
+ const assessments = new Map();
185
+ const dispositions = new Map();
186
+ const provenanceByTarget = new Map();
187
+ for (const resource of resources) {
188
+ if (resource.resourceType === 'ClinicalImpression' && identifierValue(resource, ROUTING_ASSESSMENT_SLUG)) {
189
+ assessments.set(`ClinicalImpression/${resource.id}`, resource);
190
+ } else if (resource.resourceType === 'Provenance') {
191
+ for (const target of resource.target || []) provenanceByTarget.set(target.reference, resource);
192
+ } else if (KIND_BY_RESOURCE_TYPE[resource.resourceType] && identifierValue(resource, ROUTING_DISPOSITION_SLUG)) {
193
+ dispositions.set(`${resource.resourceType}/${resource.id}`, resource);
194
+ }
195
+ }
196
+
197
+ const out = [];
198
+ for (const [assessmentRef, assessment] of assessments) {
199
+ const prov = provenanceByTarget.get(assessmentRef);
200
+ if (!prov) continue;
201
+ const dispositionRef = (prov.target || []).find((target) => dispositions.has(target.reference));
202
+ if (!dispositionRef) continue;
203
+ const disposition = dispositions.get(dispositionRef.reference);
204
+ try {
205
+ out.push(new RoutingDecision(reconstruct(assessment, disposition, prov, patientId)));
206
+ } catch (error) {
207
+ if (error instanceof ZodError) {
208
+ logger.warn({ assessmentId: assessment.id }, 'skipping malformed routing ClinicalImpression');
209
+ continue;
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ function reconstruct(assessment, disposition, prov, patientId) {
218
+ const ext = extMap(assessment);
219
+ const turnId = ext[ROUTING_EXT.TURN_ID];
220
+ const agent = prov.agent?.[0] || {};
221
+ return {
222
+ schemaVersion: '1',
223
+ decisionId: ext[ROUTING_EXT.DECISION_ID],
224
+ patientId,
225
+ turnId,
226
+ committedAt: ext[ROUTING_EXT.COMMITTED_AT],
227
+ assessment: reconstructAssessment(assessment, patientId, turnId),
228
+ disposition: reconstructDisposition(disposition, assessment, patientId),
229
+ provenance: {
230
+ agentDevice: refToId(agent.who?.reference),
231
+ onBehalfOf: refToId(agent.onBehalfOf?.reference),
232
+ recordedAt: prov.recorded,
233
+ },
234
+ };
235
+ }
236
+
237
+ function reconstructAssessment(resource, patientId, turnId) {
238
+ return {
239
+ assessmentId: identifierValue(resource, ROUTING_ASSESSMENT_SLUG),
240
+ patientId,
241
+ turnId,
242
+ performedAt: resource.effectiveDateTime,
243
+ previousId: refToId(resource.previous?.reference),
244
+ summary: resource.summary || '',
245
+ notes: (resource.note || []).map((note) => note.text),
246
+ findings: (resource.finding || []).map((finding) => {
247
+ const findingExt = extMap(finding);
248
+ return {
249
+ caseId: findingExt[ROUTING_EXT.FINDING_CASE_ID] || refToId(finding.item?.reference),
250
+ clinicalReasoning: findingExt[ROUTING_EXT.FINDING_REASONING] || '',
251
+ decisionAction: findingExt[ROUTING_EXT.FINDING_ACTION] || '',
252
+ };
253
+ }),
254
+ };
255
+ }
256
+
257
+ function reconstructDisposition(resource, assessment, patientId) {
258
+ const kind = KIND_BY_RESOURCE_TYPE[resource.resourceType];
259
+ const codec = DISPOSITION_CODECS[kind];
260
+ const deferReasons = extValues(assessment, ROUTING_EXT.DEFERRED_REASON);
261
+ return {
262
+ dispositionId: identifierValue(resource, ROUTING_DISPOSITION_SLUG),
263
+ patientId,
264
+ kind,
265
+ primaryAction: codec.actionOf(resource),
266
+ primaryReason: codec.reasonOf(resource),
267
+ primaryCaseId: extMap(assessment)[ROUTING_EXT.PRIMARY_CASE_ID] || null,
268
+ secondaryActions: (resource.action || []).slice(1).map((action) => {
269
+ const description = action.description || '';
270
+ return {
271
+ action: action.title,
272
+ composeAs: description.startsWith(COMPOSE_AS_PREFIX) ? description.slice(COMPOSE_AS_PREFIX.length) : description,
273
+ };
274
+ }),
275
+ deferredItems: extValues(assessment, ROUTING_EXT.DEFERRED_ITEM)
276
+ .map((item, i) => ({ item, deferReason: deferReasons[i] })),
277
+ authoredAt: codec.authoredOf(resource),
278
+ };
43
279
  }
44
280
 
45
281
  module.exports = {
46
- projectRoutingDecision,
47
- PROJECTOR_NAME,
282
+ toFhir,
283
+ fromFhir,
48
284
  };
@@ -7,7 +7,7 @@ const {
7
7
  extension,
8
8
  toIso,
9
9
  } = require('../helpers/elementHelper');
10
- const { ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
10
+ const { RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
11
11
  const { PALLIATIVE_EXT } = require('../constants/extensionSlugs');
12
12
 
13
13
  class CarePlan {
@@ -60,23 +60,6 @@ class CarePlan {
60
60
  return new CarePlan(payload);
61
61
  }
62
62
 
63
- static fromRoutingDisposition({ disposition }) {
64
- const payload = {
65
- resourceType: 'CarePlan',
66
- id: fhirId(disposition.dispositionId),
67
- identifier: [identifier(ROUTING_DISPOSITION_SLUG, disposition.dispositionId)],
68
- status: 'active',
69
- intent: 'plan',
70
- category: [codeableConcept(disposition.primaryAction)],
71
- subject: patientReference(disposition.patientId),
72
- created: toIso(disposition.authoredAt),
73
- description: disposition.primaryReason,
74
- };
75
- if (disposition.primaryCaseId) {
76
- payload.addresses = [{ reference: reference('Condition', disposition.primaryCaseId) }];
77
- }
78
- return new CarePlan(payload);
79
- }
80
63
  }
81
64
 
82
65
  module.exports = {
@@ -1,7 +1,7 @@
1
1
  const { fhirId } = require('../helpers/fhirHelper');
2
2
  const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
3
- const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_ASSESSMENT_SLUG, RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
4
- const { ROUTING_EXT, CLUSTER_EXT, PALLIATIVE_EXT } = require('../constants/extensionSlugs');
3
+ const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
4
+ const { CLUSTER_EXT, PALLIATIVE_EXT } = require('../constants/extensionSlugs');
5
5
 
6
6
  const CLUSTER_STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
7
7
  const CLUSTER_LIST_SEPARATOR = '; ';
@@ -75,47 +75,6 @@ class ClinicalImpression {
75
75
  return new ClinicalImpression(payload);
76
76
  }
77
77
 
78
- static fromRoutingAssessment({ assessment, decisionId, committedAt, deferredItems = [], primaryCaseId = null }) {
79
- const extensions = [
80
- extension(ROUTING_EXT.TURN_ID, { valueString: assessment.turnId }),
81
- extension(ROUTING_EXT.DECISION_ID, { valueString: decisionId }),
82
- extension(ROUTING_EXT.COMMITTED_AT, { valueDateTime: toIso(committedAt) }),
83
- ];
84
- if (primaryCaseId) extensions.push(extension(ROUTING_EXT.PRIMARY_CASE_ID, { valueString: primaryCaseId }));
85
- for (const deferred of deferredItems) {
86
- extensions.push(extension(ROUTING_EXT.DEFERRED_ITEM, { valueString: deferred.item }));
87
- extensions.push(extension(ROUTING_EXT.DEFERRED_REASON, { valueString: deferred.deferReason }));
88
- }
89
- const payload = {
90
- resourceType: 'ClinicalImpression',
91
- id: fhirId(assessment.assessmentId),
92
- identifier: [identifier(ROUTING_ASSESSMENT_SLUG, assessment.assessmentId)],
93
- status: 'completed',
94
- subject: patientReference(assessment.patientId),
95
- effectiveDateTime: toIso(assessment.performedAt),
96
- finding: assessment.findings.map((finding) => {
97
- const findingExts = [];
98
- const projected = {};
99
- // A caseless mention (caseId null) has no case to point at: no Condition item and no case-id extension, so absence stays absence in the FHIR.
100
- if (finding.caseId) {
101
- projected.item = reference('Condition', finding.caseId);
102
- findingExts.push(extension(ROUTING_EXT.FINDING_CASE_ID, { valueString: finding.caseId }));
103
- }
104
- findingExts.push(
105
- extension(ROUTING_EXT.FINDING_REASONING, { valueString: finding.clinicalReasoning }),
106
- extension(ROUTING_EXT.FINDING_ACTION, { valueString: finding.decisionAction }),
107
- );
108
- projected.extension = findingExts;
109
- return projected;
110
- }),
111
- extension: extensions,
112
- };
113
- if (assessment.summary) payload.summary = assessment.summary;
114
- if (assessment.previousId) payload.previous = reference('ClinicalImpression', assessment.previousId);
115
- if (assessment.notes.length) payload.note = assessment.notes.map((text) => ({ text }));
116
- return new ClinicalImpression(payload);
117
- }
118
-
119
78
  static fromPalliativeAssessment({ assessment }) {
120
79
  const payload = {
121
80
  resourceType: 'ClinicalImpression',
@@ -1,7 +1,6 @@
1
1
  const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
2
2
  const {
3
3
  identifier,
4
- reference,
5
4
  patientReference,
6
5
  recipientReference,
7
6
  codeableConcept,
@@ -15,7 +14,6 @@ const {
15
14
  REMINDER_SLUG,
16
15
  REMINDER_KIND_SLUG,
17
16
  COMMUNICATION_CHANNEL_SLUG,
18
- ROUTING_DISPOSITION_SLUG,
19
17
  } = require('../constants/projectionSlugs');
20
18
  const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
21
19
 
@@ -88,24 +86,6 @@ class CommunicationRequest {
88
86
  return new CommunicationRequest(payload);
89
87
  }
90
88
 
91
- static fromRoutingDisposition({ disposition }) {
92
- const payload = {
93
- resourceType: 'CommunicationRequest',
94
- id: fhirId(disposition.dispositionId),
95
- identifier: [identifier(ROUTING_DISPOSITION_SLUG, disposition.dispositionId)],
96
- status: 'active',
97
- intent: 'plan',
98
- category: [codeableConcept(disposition.primaryAction)],
99
- subject: patientReference(disposition.patientId),
100
- authoredOn: toIso(disposition.authoredAt),
101
- payload: [{ contentCodeableConcept: codeableConcept(disposition.primaryReason) }],
102
- recipient: [patientReference(disposition.patientId)],
103
- };
104
- if (disposition.primaryCaseId) {
105
- payload.reason = [{ reference: reference('Condition', disposition.primaryCaseId) }];
106
- }
107
- return new CommunicationRequest(payload);
108
- }
109
89
  }
110
90
 
111
91
  module.exports = { CommunicationRequest };
@@ -56,17 +56,6 @@ class Provenance {
56
56
  });
57
57
  }
58
58
 
59
- static fromRoutingDecision({ provenanceId, targetRefs, provenance, activityText }) {
60
- return new Provenance({
61
- resourceType: 'Provenance',
62
- id: fhirId(provenanceId),
63
- target: targetRefs,
64
- recorded: toIso(provenance.recordedAt),
65
- agent: [{ who: reference('Device', provenance.agentDevice), onBehalfOf: reference('Organization', provenance.onBehalfOf) }],
66
- activity: codeableConcept(activityText),
67
- });
68
- }
69
-
70
59
  static fromSymptomCase({ provenanceId, targetRefs, recordedAt, activityText }) {
71
60
  return new Provenance({
72
61
  resourceType: 'Provenance',
@@ -12,7 +12,6 @@ const {
12
12
  const {
13
13
  ESCALATION_SLUG,
14
14
  ESCALATION_KIND_SLUG,
15
- ROUTING_DISPOSITION_SLUG,
16
15
  RECOMMENDATION_REC_SLUG,
17
16
  PALLIATIVE_ESCALATION_SLUG,
18
17
  PALLIATIVE_CAREPLAN_REC_SLUG,
@@ -148,24 +147,6 @@ class Task {
148
147
  return new Task(payload);
149
148
  }
150
149
 
151
- static fromRoutingDisposition({ disposition }) {
152
- const payload = {
153
- resourceType: 'Task',
154
- id: fhirId(disposition.dispositionId),
155
- identifier: [identifier(ROUTING_DISPOSITION_SLUG, disposition.dispositionId)],
156
- status: 'requested',
157
- intent: 'order',
158
- code: codeableConcept(disposition.primaryAction),
159
- description: disposition.primaryReason,
160
- for: patientReference(disposition.patientId),
161
- authoredOn: toIso(disposition.authoredAt),
162
- requester: reference('Device', getDeviceId()),
163
- };
164
- if (disposition.primaryCaseId) {
165
- payload.reason = [{ reference: reference('Condition', disposition.primaryCaseId) }];
166
- }
167
- return new Task(payload);
168
- }
169
150
  }
170
151
 
171
152
  module.exports = { Task };
@@ -1,148 +1,36 @@
1
- const { ZodError } = require('zod');
2
-
3
1
  const { fhirId } = require('../helpers/fhirHelper');
4
- const { extMap, extValues, identifierValue, refToId } = require('../helpers/fhirReadHelper');
5
- const { ROUTING_ASSESSMENT_SLUG, ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
6
- const { ROUTING_EXT } = require('../constants/extensionSlugs');
7
- const { PROJECTOR_NAME } = require('../projections/routingProjection');
2
+ const { ROUTING_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
8
3
  const { getFhirStore } = require('../stores/fhirStore');
9
- const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
10
- const { logger } = require('../../utils/logger');
11
- const { project, storeResources } = require('./fhirService');
12
-
13
- const DISPOSITION_KIND_BY_RESOURCE = {
14
- Communication: 'communication',
15
- CommunicationRequest: 'communication_request',
16
- Task: 'task',
17
- RequestOrchestration: 'orchestration',
18
- CarePlan: 'care_plan',
19
- };
4
+ const { toFhir, fromFhir } = require('../projections/routingProjection');
5
+ const { storeResources } = require('./fhirService');
20
6
 
21
7
  async function storeRoutingDecision({ patientId, routingDecision }) {
22
8
  if (routingDecision.patientId !== patientId) {
23
9
  throw new Error(`storeRoutingDecision patientId mismatch: expected ${patientId}`);
24
10
  }
25
- const bundle = project([{ name: PROJECTOR_NAME, aggregate: routingDecision }]);
26
- const resources = bundle.entry.map((entry) => entry.resource);
27
- const { stored } = await storeResources(resources);
11
+ const { stored } = await storeResources(toFhir(routingDecision));
28
12
  return { count: stored.length, ids: stored };
29
13
  }
30
14
 
31
- async function readRoutingByTurn({ patientId, turnId }) {
15
+ async function readRoutingDecisions({ patientId, turnId = null }) {
32
16
  const store = getFhirStore();
33
17
  const patient = `Patient/${fhirId(patientId)}`;
34
- const impressions = (await store.find({ patient, resourceType: 'ClinicalImpression' }))
35
- .filter((resource) => identifierValue(resource, ROUTING_ASSESSMENT_SLUG));
36
- const assessment = impressions.find((impression) => extMap(impression)[ROUTING_EXT.TURN_ID] === turnId);
37
- if (!assessment) return null;
38
- const provenance = await store.getByReference(`Provenance/${fhirId(`${assessment.id}-prov`)}`);
39
- if (!provenance) return null;
40
- const disposition = await findDisposition(store, assessment, provenance);
41
- if (!disposition) return null;
42
- try {
43
- return new RoutingDecision(reconstructRouting(assessment, disposition, provenance, patientId, turnId));
44
- } catch (error) {
45
- if (error instanceof ZodError) {
46
- logger.warn({ assessmentId: assessment.id }, 'skipping malformed routing ClinicalImpression');
47
- return null;
48
- }
49
- throw error;
50
- }
51
- }
52
-
53
- async function findDisposition(store, assessment, provenance) {
54
- const assessmentRef = `ClinicalImpression/${fhirId(assessment.id)}`;
55
- const dispositionRef = (provenance.target || []).find((target) => target.reference !== assessmentRef);
56
- if (!dispositionRef) return null;
57
- const resource = await store.getByReference(dispositionRef.reference);
58
- return resource && identifierValue(resource, ROUTING_DISPOSITION_SLUG) ? resource : null;
59
- }
60
-
61
- function reconstructRouting(assessment, disposition, provenance, patientId, turnId) {
62
- const ext = extMap(assessment);
63
- const agent = (provenance.agent || [])[0] || {};
64
- return {
65
- schemaVersion: '1',
66
- decisionId: ext[ROUTING_EXT.DECISION_ID],
67
- patientId,
68
- turnId,
69
- committedAt: ext[ROUTING_EXT.COMMITTED_AT],
70
- assessment: reconstructAssessment(assessment, patientId, turnId),
71
- disposition: reconstructDisposition(disposition, assessment, patientId),
72
- provenance: {
73
- agentDevice: refToId(agent.who && agent.who.reference),
74
- onBehalfOf: refToId(agent.onBehalfOf && agent.onBehalfOf.reference),
75
- recordedAt: provenance.recorded,
76
- },
77
- };
78
- }
79
-
80
- function reconstructAssessment(resource, patientId, turnId) {
81
- return {
82
- assessmentId: identifierValue(resource, ROUTING_ASSESSMENT_SLUG),
83
- patientId,
18
+ const assessments = await store.find({
19
+ patient,
20
+ resourceType: 'ClinicalImpression',
84
21
  turnId,
85
- performedAt: resource.effectiveDateTime,
86
- previousId: resource.previous ? refToId(resource.previous.reference) : null,
87
- summary: resource.summary || '',
88
- notes: (resource.note || []).map((note) => note.text),
89
- findings: (resource.finding || []).map(reconstructFinding),
90
- };
91
- }
92
-
93
- function reconstructFinding(finding) {
94
- const ext = extMap(finding);
95
- return {
96
- caseId: ext[ROUTING_EXT.FINDING_CASE_ID] || refToId(finding.item && finding.item.reference),
97
- clinicalReasoning: ext[ROUTING_EXT.FINDING_REASONING] || '',
98
- decisionAction: ext[ROUTING_EXT.FINDING_ACTION] || '',
99
- };
100
- }
101
-
102
- function reconstructDisposition(resource, assessment, patientId) {
103
- return {
104
- dispositionId: identifierValue(resource, ROUTING_DISPOSITION_SLUG),
105
- patientId,
106
- kind: DISPOSITION_KIND_BY_RESOURCE[resource.resourceType],
107
- primaryAction: dispositionAction(resource),
108
- primaryReason: dispositionReason(resource),
109
- primaryCaseId: extMap(assessment)[ROUTING_EXT.PRIMARY_CASE_ID] || null,
110
- secondaryActions: (resource.action || []).slice(1).map(reconstructSecondary),
111
- deferredItems: reconstructDeferredItems(assessment),
112
- authoredAt: resource.sent || resource.authoredOn || resource.created,
113
- };
114
- }
115
-
116
- function reconstructDeferredItems(assessment) {
117
- const items = extValues(assessment, ROUTING_EXT.DEFERRED_ITEM);
118
- const reasons = extValues(assessment, ROUTING_EXT.DEFERRED_REASON);
119
- return items.map((item, i) => ({ item, deferReason: reasons[i] }));
120
- }
121
-
122
- function reconstructSecondary(action) {
123
- const description = action.description || '';
124
- return {
125
- action: action.title,
126
- composeAs: description.startsWith('compose_as: ') ? description.slice('compose_as: '.length) : description,
127
- };
128
- }
129
-
130
- function dispositionAction(resource) {
131
- if (resource.resourceType === 'Task') return ccText(resource.code);
132
- if (resource.resourceType === 'RequestOrchestration') return (resource.action || [])[0] && resource.action[0].title;
133
- return ccText((resource.category || [])[0]);
22
+ identifierSlug: ROUTING_ASSESSMENT_SLUG,
23
+ });
24
+ const bundles = await Promise.all(assessments.map(async (assessment) => {
25
+ const provenance = await store.getByReference(`Provenance/${fhirId(`${assessment.id}-prov`)}`);
26
+ if (!provenance) return [];
27
+ const targets = await Promise.all((provenance.target || []).map((target) => store.getByReference(target.reference)));
28
+ return [assessment, provenance, ...targets.filter(Boolean)];
29
+ }));
30
+ return fromFhir(bundles.flat(), { patientId });
134
31
  }
135
32
 
136
- function dispositionReason(resource) {
137
- if (resource.resourceType === 'Task') return resource.description;
138
- if (resource.resourceType === 'CarePlan') return resource.description;
139
- if (resource.resourceType === 'RequestOrchestration') return (resource.action || [])[0] && resource.action[0].description;
140
- return ccText((resource.payload || [])[0] && resource.payload[0].contentCodeableConcept);
141
- }
142
-
143
- const ccText = (concept) => (concept && concept.text) || null;
144
-
145
33
  module.exports = {
146
34
  storeRoutingDecision,
147
- readRoutingByTurn,
35
+ readRoutingDecisions,
148
36
  };
@@ -82,10 +82,11 @@ class FhirStore {
82
82
  return envelope ? envelope.resource : null;
83
83
  }
84
84
 
85
- async find({ patient = null, resourceType = null, symptomTerm = null, identifierSlug = null } = {}) {
85
+ async find({ patient = null, resourceType = null, symptomTerm = null, identifierSlug = null, turnId = null } = {}) {
86
86
  const query = { 'meta.deleted': false };
87
87
  if (patient) query['index.patient'] = patient;
88
88
  if (symptomTerm) query['index.symptomTerm'] = symptomTerm;
89
+ if (turnId) query['index.turnId'] = turnId;
89
90
  if (identifierSlug) query['resource.identifier.system'] = identifierSystem(identifierSlug);
90
91
  if (resourceType) {
91
92
  const model = await this._modelFor(resourceType);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.13.0-dev.1149",
3
+ "version": "5.13.0-dev.1151",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -1,29 +0,0 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const { identifier, reference, patientReference, codeableConcept, toIso } = require('../helpers/elementHelper');
3
- const { ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
4
-
5
- class Communication {
6
- constructor(payload) {
7
- Object.assign(this, payload);
8
- }
9
-
10
- static fromRoutingDisposition({ disposition }) {
11
- const payload = {
12
- resourceType: 'Communication',
13
- id: fhirId(disposition.dispositionId),
14
- identifier: [identifier(ROUTING_DISPOSITION_SLUG, disposition.dispositionId)],
15
- status: 'completed',
16
- category: [codeableConcept(disposition.primaryAction)],
17
- subject: patientReference(disposition.patientId),
18
- sent: toIso(disposition.authoredAt),
19
- payload: [{ contentCodeableConcept: codeableConcept(disposition.primaryReason) }],
20
- recipient: [patientReference(disposition.patientId)],
21
- };
22
- if (disposition.primaryCaseId) {
23
- payload.reason = [{ reference: reference('Condition', disposition.primaryCaseId) }];
24
- }
25
- return new Communication(payload);
26
- }
27
- }
28
-
29
- module.exports = { Communication };
@@ -1,46 +0,0 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const {
3
- identifier,
4
- patientReference,
5
- reference,
6
- codeableConcept,
7
- toIso,
8
- } = require('../helpers/elementHelper');
9
- const { ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
10
-
11
- class RequestOrchestration {
12
- constructor(payload) {
13
- Object.assign(this, payload);
14
- }
15
-
16
- static fromRoutingDisposition({ disposition }) {
17
- const action = [{
18
- title: disposition.primaryAction,
19
- description: disposition.primaryReason,
20
- code: [codeableConcept(disposition.primaryAction)],
21
- }];
22
- for (const secondary of disposition.secondaryActions) {
23
- action.push({
24
- title: secondary.action,
25
- description: `compose_as: ${secondary.composeAs}`,
26
- code: [codeableConcept(secondary.action)],
27
- });
28
- }
29
- const payload = {
30
- resourceType: 'RequestOrchestration',
31
- id: fhirId(disposition.dispositionId),
32
- identifier: [identifier(ROUTING_DISPOSITION_SLUG, disposition.dispositionId)],
33
- status: 'active',
34
- intent: 'plan',
35
- subject: patientReference(disposition.patientId),
36
- authoredOn: toIso(disposition.authoredAt),
37
- action,
38
- };
39
- if (disposition.primaryCaseId) {
40
- payload.reason = [{ reference: reference('Condition', disposition.primaryCaseId) }];
41
- }
42
- return new RequestOrchestration(payload);
43
- }
44
- }
45
-
46
- module.exports = { RequestOrchestration };