@peopl-health/nexus 5.12.2 → 5.12.4

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.
@@ -25,6 +25,18 @@ const GRADE_EXT = {
25
25
  THRESHOLD_CLAUSE: 'grade-threshold-clause',
26
26
  };
27
27
 
28
+ const MENTION_EXT = {
29
+ ASSERTION_NEGATED: 'mention-assertion-negated',
30
+ ASSERTION_HEDGED: 'mention-assertion-hedged',
31
+ ASSERTION_EXPERIENCER: 'mention-assertion-experiencer',
32
+ ASSERTION_TEMPORAL: 'mention-assertion-temporal',
33
+ ASSERTION_CONFIDENCE: 'mention-assertion-confidence',
34
+ TEMPORALITY: 'mention-temporality',
35
+ TURN_ID: 'mention-turn-id',
36
+ SOURCE: 'mention-source',
37
+ EXTRACTOR_CONFIDENCE: 'mention-extractor-confidence',
38
+ };
39
+
28
40
  const ROUTING_EXT = {
29
41
  TURN_ID: 'routing-turn-id',
30
42
  DECISION_ID: 'routing-decision-id',
@@ -35,6 +47,7 @@ const ROUTING_EXT = {
35
47
  FINDING_CASE_ID: 'routing-finding-case-id',
36
48
  FINDING_REASONING: 'routing-finding-reasoning',
37
49
  FINDING_ACTION: 'routing-finding-action',
50
+ PREVIOUS_ID: 'routing-previous-id',
38
51
  };
39
52
 
40
53
  const CLUSTER_EXT = {
@@ -46,6 +59,7 @@ const CLUSTER_EXT = {
46
59
  STATUS: 'cluster-status',
47
60
  CONSULTATION_ID: 'cluster-consultation-id',
48
61
  DISSOLVE_REASON: 'cluster-dissolve-reason',
62
+ MEMBER_CASE_ID: 'cluster-member-case-id',
49
63
  };
50
64
 
51
65
  const PALLIATIVE_EXT = {
@@ -87,6 +101,7 @@ const RISK_EXT = {
87
101
 
88
102
  module.exports = {
89
103
  SYMPTOM_CASE_EXT,
104
+ MENTION_EXT,
90
105
  ASSESSMENT_EXT,
91
106
  GRADE_EXT,
92
107
  ROUTING_EXT,
@@ -34,6 +34,10 @@ function notes(...texts) {
34
34
  return present.length ? present : null;
35
35
  }
36
36
 
37
+ function toInstant(value) {
38
+ return new Date(value).toISOString();
39
+ }
40
+
37
41
  function toIso(value) {
38
42
  if (value instanceof Date) return value.toISOString().replace(/\.000Z$/, 'Z');
39
43
  const s = String(value);
@@ -50,5 +54,6 @@ module.exports = {
50
54
  codeableConcept,
51
55
  extension,
52
56
  notes,
57
+ toInstant,
53
58
  toIso,
54
59
  };
@@ -34,9 +34,20 @@ function extValues(resource, name) {
34
34
  return values;
35
35
  }
36
36
 
37
+ function referencedId(node, slug, reference) {
38
+ return extValues(node, slug)[0] || refToId(reference);
39
+ }
40
+
41
+ function referencedIds(node, slug, references) {
42
+ const values = extValues(node, slug);
43
+ return values.length ? values : references.map(refToId).filter(Boolean);
44
+ }
45
+
37
46
  module.exports = {
38
47
  extMap,
39
48
  identifierValue,
40
49
  refToId,
41
50
  extValues,
51
+ referencedId,
52
+ referencedIds,
42
53
  };
@@ -2,12 +2,12 @@ const { getDeviceId, getOrganizationId, getSystems } = require('../config/fhirCo
2
2
  const { fhirId } = require('./fhirHelper');
3
3
  const { identifier, patientReference, reference, codeableConcept, toIso } = require('./elementHelper');
4
4
 
5
- function baseResource(resourceType, id, slug, patientId) {
5
+ function baseResource(resourceType, id, slug, patientId = null) {
6
6
  return {
7
7
  resourceType,
8
8
  id: fhirId(id),
9
9
  identifier: [identifier(slug, id)],
10
- subject: patientReference(patientId),
10
+ ...(patientId ? { subject: patientReference(patientId) } : {}),
11
11
  };
12
12
  }
13
13
 
@@ -25,6 +25,11 @@ function provenance({ provenanceId, targetRefs, recordedAt, activity, agentDevic
25
25
  };
26
26
  }
27
27
 
28
+ function problemListCategory() {
29
+ const code = 'problem-list-item';
30
+ return codeableConcept(code, { code, system: getSystems().conditionCategory });
31
+ }
32
+
28
33
  function observationCategory(code) {
29
34
  const label = code.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
30
35
  return codeableConcept(label, { code, system: getSystems().observationCategory, display: label });
@@ -33,5 +38,6 @@ function observationCategory(code) {
33
38
  module.exports = {
34
39
  baseResource,
35
40
  provenance,
41
+ problemListCategory,
36
42
  observationCategory,
37
43
  };
@@ -1,65 +1,195 @@
1
- const { getSystems } = require('../config/fhirConfig');
2
- const { codeableConcept, extension } = require('../helpers/elementHelper');
1
+ const { getDeviceId, getOrganizationId, getSystems } = require('../config/fhirConfig');
2
+ const { stringifyValue } = require('../utils/normalizeUtils');
3
+ const { fhirId } = require('../helpers/fhirHelper');
4
+ const { reference, patientReference, codeableConcept, extension, toInstant } = require('../helpers/elementHelper');
5
+ const { baseResource, problemListCategory, observationCategory } = require('../helpers/resourceHelper');
3
6
  const { MENTION_SLUG } = require('../constants/projectionSlugs');
4
- const { Observation } = require('../resources/Observation');
5
- const { Condition } = require('../resources/Condition');
6
- const { MedicationStatement } = require('../resources/MedicationStatement');
7
- const { Procedure } = require('../resources/Procedure');
8
- const { Appointment } = require('../resources/Appointment');
9
- const { Provenance } = require('../resources/Provenance');
10
-
11
- const PROJECTOR_NAME = 'clinicalMention';
7
+ const { MENTION_EXT } = require('../constants/extensionSlugs');
8
+
12
9
  const ACTIVITY_TEXT = 'clinical-mention-extraction';
10
+ const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
13
11
 
14
12
  const DEDICATED_AGGREGATE = new Set(['comorbidity', 'allergy', 'performance_status']);
15
13
 
16
- function projectClinicalMention(mention) {
17
- const { fact, provenance } = mention;
14
+ const CATEGORY_TO_OBSERVATION_CATEGORY = {
15
+ symptom: 'survey',
16
+ lab_value: 'laboratory',
17
+ emotion: 'survey',
18
+ indication: 'exam',
19
+ diagnosis: 'exam',
20
+ };
21
+
22
+ const DIAGNOSIS_CATEGORIES = new Set(['diagnosis', 'indication']);
18
23
 
24
+ function toFhir(mention) {
25
+ const { fact, provenance } = mention;
19
26
  if (DEDICATED_AGGREGATE.has(fact.category)) return [];
27
+ const primary = primaryResource(mention);
28
+ return [primary, mentionProvenance(primary, provenance)];
29
+ }
20
30
 
21
- const assertion = fact.assertion;
22
- const extensions = [
23
- extension('mention-assertion-negated', { valueBoolean: assertion.negated }),
24
- extension('mention-assertion-hedged', { valueBoolean: assertion.hedged }),
25
- extension('mention-assertion-experiencer', { valueString: assertion.experiencer }),
26
- extension('mention-assertion-temporal', { valueString: assertion.temporal }),
27
- extension('mention-assertion-confidence', { valueDecimal: assertion.confidence }),
28
- ];
29
- if (fact.temporality) {
30
- extensions.push(extension('mention-temporality', { valueString: fact.temporality }));
31
- }
31
+ const BUILDER_BY_CATEGORY = {
32
+ medication: medicationStatementResource,
33
+ adherence: medicationStatementResource,
34
+ treatment: procedureResource,
35
+ appointment: appointmentResource,
36
+ };
37
+
38
+ function primaryResource(mention) {
39
+ const { fact } = mention;
40
+ const build = BUILDER_BY_CATEGORY[fact.category];
41
+ if (build) return build(mention);
42
+ const currentAffirmedDiagnosis = DIAGNOSIS_CATEGORIES.has(fact.category)
43
+ && fact.assertion.temporal === 'current'
44
+ && !fact.assertion.negated;
45
+ return currentAffirmedDiagnosis ? conditionResource(mention) : observationResource(mention);
46
+ }
32
47
 
33
- const code = fact.ctcaeTerm
48
+ function mentionCode(fact) {
49
+ return fact.ctcaeTerm
34
50
  ? codeableConcept(fact.canonicalTerm, { code: fact.ctcaeTerm, system: getSystems().ctcae, display: fact.ctcaeTerm })
35
51
  : codeableConcept(fact.canonicalTerm);
36
- const primary = primaryResource({ fact, provenance, code, extensions });
37
- return [primary, Provenance.fromMentionProjection({ target: primary, provenance, activityText: ACTIVITY_TEXT })];
38
52
  }
39
53
 
40
- function primaryResource({ fact, provenance, code, extensions }) {
41
- const assertion = fact.assertion;
42
- const args = { fact, provenance, code, extensions, slug: MENTION_SLUG };
43
- if (fact.category === 'medication' || fact.category === 'adherence') {
44
- return MedicationStatement.fromMentionProjection(args);
45
- }
46
- if (fact.category === 'treatment') {
47
- return Procedure.fromMentionProjection(args);
54
+ function mentionExtensions(fact) {
55
+ const { assertion } = fact;
56
+ const out = [
57
+ extension(MENTION_EXT.ASSERTION_NEGATED, { valueBoolean: assertion.negated }),
58
+ extension(MENTION_EXT.ASSERTION_HEDGED, { valueBoolean: assertion.hedged }),
59
+ extension(MENTION_EXT.ASSERTION_EXPERIENCER, { valueString: assertion.experiencer }),
60
+ extension(MENTION_EXT.ASSERTION_TEMPORAL, { valueString: assertion.temporal }),
61
+ extension(MENTION_EXT.ASSERTION_CONFIDENCE, { valueDecimal: assertion.confidence }),
62
+ ];
63
+ if (fact.temporality) out.push(extension(MENTION_EXT.TEMPORALITY, { valueString: fact.temporality }));
64
+ return out;
65
+ }
66
+
67
+ const quoteNotes = (provenance) => {
68
+ const quotes = provenance.verbatimQuotes || [];
69
+ return quotes.length ? quotes.map((text) => ({ text })) : null;
70
+ };
71
+
72
+ function observationResource({ fact, provenance }) {
73
+ const categoryCode = CATEGORY_TO_OBSERVATION_CATEGORY[fact.category] || 'exam';
74
+ const payload = {
75
+ ...baseResource('Observation', fact.mentionId, MENTION_SLUG, fact.patientId),
76
+ status: 'preliminary',
77
+ category: [observationCategory(categoryCode)],
78
+ code: mentionCode(fact),
79
+ performer: [patientReference(fact.patientId)],
80
+ effectiveDateTime: toInstant(fact.reportedAt),
81
+ issued: toInstant(provenance.recordedAt),
82
+ extension: mentionExtensions(fact),
83
+ };
84
+ if (fact.assertion.negated) {
85
+ payload.interpretation = [codeableConcept('Negative', { code: 'NEG', system: getSystems().observationInterpretation, display: 'Negative' })];
86
+ payload.valueCodeableConcept = codeableConcept('Ausente (negado por el paciente)');
48
87
  }
49
- if (fact.category === 'appointment') {
50
- return Appointment.fromMentionProjection(args);
88
+ const quotes = quoteNotes(provenance);
89
+ const components = observedFieldComponents(fact.observedFields);
90
+ if (quotes) payload.note = quotes;
91
+ if (components.length) payload.component = components;
92
+ return payload;
93
+ }
94
+
95
+ function conditionResource({ fact, provenance }) {
96
+ const systems = getSystems();
97
+ const verification = fact.assertion.hedged ? 'unconfirmed' : 'provisional';
98
+ const payload = {
99
+ ...baseResource('Condition', fact.mentionId, MENTION_SLUG, fact.patientId),
100
+ clinicalStatus: codeableConcept('active', { code: 'active', system: systems.conditionClinical }),
101
+ verificationStatus: codeableConcept(verification, { code: verification, system: systems.conditionVerStatus }),
102
+ category: [problemListCategory()],
103
+ code: mentionCode(fact),
104
+ recordedDate: toInstant(provenance.recordedAt),
105
+ extension: mentionExtensions(fact),
106
+ };
107
+ const quotes = quoteNotes(provenance);
108
+ if (quotes) payload.note = quotes;
109
+ return payload;
110
+ }
111
+
112
+ function medicationStatementResource({ fact, provenance }) {
113
+ const payload = {
114
+ ...baseResource('MedicationStatement', fact.mentionId, MENTION_SLUG, fact.patientId),
115
+ status: 'recorded',
116
+ medication: { concept: mentionCode(fact) },
117
+ effectiveDateTime: toInstant(fact.reportedAt),
118
+ extension: mentionExtensions(fact),
119
+ };
120
+ const adherence = adherenceFor(fact.assertion);
121
+ if (adherence) {
122
+ payload.adherence = {
123
+ code: codeableConcept(adherence, { code: adherence, system: getSystems().medicationStatementAdherence }),
124
+ };
51
125
  }
52
- const currentAffirmedDiagnosis =
53
- (fact.category === 'diagnosis' || fact.category === 'indication') &&
54
- assertion.temporal === 'current' &&
55
- !assertion.negated;
56
- if (currentAffirmedDiagnosis) {
57
- return Condition.fromMentionProjection(args);
126
+ const quotes = quoteNotes(provenance);
127
+ if (quotes) payload.note = quotes;
128
+ return payload;
129
+ }
130
+
131
+ function adherenceFor(assertion) {
132
+ if (assertion.negated) return 'not-taking';
133
+ if (assertion.temporal === 'historical') return 'stopped';
134
+ return null;
135
+ }
136
+
137
+ function procedureResource({ fact, provenance }) {
138
+ const payload = {
139
+ ...baseResource('Procedure', fact.mentionId, MENTION_SLUG, fact.patientId),
140
+ status: fact.assertion.negated ? 'not-done' : 'completed',
141
+ code: mentionCode(fact),
142
+ occurrenceDateTime: toInstant(fact.reportedAt),
143
+ recorder: patientReference(fact.patientId),
144
+ extension: mentionExtensions(fact),
145
+ };
146
+ const quotes = quoteNotes(provenance);
147
+ if (quotes) payload.note = quotes;
148
+ return payload;
149
+ }
150
+
151
+ function appointmentResource({ fact, provenance }) {
152
+ const payload = {
153
+ ...baseResource('Appointment', fact.mentionId, MENTION_SLUG),
154
+ status: fact.assertion.negated ? 'cancelled' : 'proposed',
155
+ description: fact.canonicalTerm,
156
+ participant: [{ actor: patientReference(fact.patientId), status: 'accepted' }],
157
+ extension: mentionExtensions(fact),
158
+ };
159
+ const quotes = quoteNotes(provenance);
160
+ if (quotes) payload.note = quotes;
161
+ return payload;
162
+ }
163
+
164
+ function mentionProvenance(target, provenance) {
165
+ return {
166
+ resourceType: 'Provenance',
167
+ id: fhirId(`${provenance.mentionId}-prov`),
168
+ target: [reference(target.resourceType, target.id)],
169
+ recorded: toInstant(provenance.recordedAt),
170
+ agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
171
+ activity: codeableConcept(ACTIVITY_TEXT),
172
+ extension: [
173
+ extension(MENTION_EXT.TURN_ID, { valueString: provenance.turnId }),
174
+ extension(MENTION_EXT.SOURCE, { valueString: provenance.source }),
175
+ extension(MENTION_EXT.EXTRACTOR_CONFIDENCE, { valueDecimal: provenance.extractorConfidence }),
176
+ ],
177
+ };
178
+ }
179
+
180
+ function observedFieldComponents(observedFields) {
181
+ const components = [];
182
+ for (const [key, raw] of Object.entries(observedFields || {})) {
183
+ const text = stringifyValue(raw);
184
+ if (!text) continue;
185
+ components.push({
186
+ code: codeableConcept(key, { code: key, system: getSystems()[OBSERVED_FIELD_SYSTEM_KEY] }),
187
+ valueString: text,
188
+ });
58
189
  }
59
- return Observation.fromMentionProjection(args);
190
+ return components;
60
191
  }
61
192
 
62
193
  module.exports = {
63
- projectClinicalMention,
64
- PROJECTOR_NAME,
194
+ toFhir,
65
195
  };
@@ -3,7 +3,7 @@ const { ZodError } = require('zod');
3
3
  const { codeSystemUrl } = require('../helpers/fhirHelper');
4
4
  const { identifier, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
5
5
  const { baseResource, provenance } = require('../helpers/resourceHelper');
6
- const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
6
+ const { extMap, identifierValue, referencedIds } = require('../helpers/fhirReadHelper');
7
7
  const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG } = require('../constants/projectionSlugs');
8
8
  const { CLUSTER_EXT } = require('../constants/extensionSlugs');
9
9
  const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
@@ -54,6 +54,7 @@ function clusterExtensions(cluster) {
54
54
  if (cluster.weakensIf.length) {
55
55
  out.push(extension(CLUSTER_EXT.WEAKENS_IF, { valueString: cluster.weakensIf.join(LIST_SEPARATOR) }));
56
56
  }
57
+ for (const caseId of cluster.memberCaseIds) out.push(extension(CLUSTER_EXT.MEMBER_CASE_ID, { valueString: caseId }));
57
58
  out.push(extension(CLUSTER_EXT.STATUS, { valueString: cluster.status }));
58
59
  if (cluster.consultationId) out.push(extension(CLUSTER_EXT.CONSULTATION_ID, { valueString: cluster.consultationId }));
59
60
  if (cluster.dissolveReason) out.push(extension(CLUSTER_EXT.DISSOLVE_REASON, { valueString: cluster.dissolveReason }));
@@ -104,7 +105,7 @@ function reconstruct(resource, patientId) {
104
105
  status: ext[CLUSTER_EXT.STATUS],
105
106
  confidence: ext[CLUSTER_EXT.CONFIDENCE],
106
107
  clinicalSignificance: ext[CLUSTER_EXT.CLINICAL_SIGNIFICANCE],
107
- memberCaseIds: (resource.problem || []).map((problemRef) => refToId(problemRef.reference)).filter(Boolean),
108
+ memberCaseIds: referencedIds(resource, CLUSTER_EXT.MEMBER_CASE_ID, (resource.problem || []).map((p) => p.reference)),
108
109
  consultationId: ext[CLUSTER_EXT.CONSULTATION_ID] || null,
109
110
  relationshipModel: resource.summary || '',
110
111
  strengthensIf: splitList(ext[CLUSTER_EXT.STRENGTHENS_IF]),
@@ -4,7 +4,7 @@ const { getOrganizationId } = require('../config/fhirConfig');
4
4
  const { fhirId } = require('../helpers/fhirHelper');
5
5
  const { patientReference, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
6
6
  const { baseResource, provenance } = require('../helpers/resourceHelper');
7
- const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
7
+ const { extMap, identifierValue, referencedId } = require('../helpers/fhirReadHelper');
8
8
  const { CONTINGENCY_PLAN_SLUG, CONTINGENCY_STEP_SLUG } = require('../constants/projectionSlugs');
9
9
  const { ContingencySafetyNet } = require('../../shared/dtos/ContingencySafetyNet');
10
10
  const { logger } = require('../../utils/logger');
@@ -18,6 +18,8 @@ const EXT = {
18
18
  WHY_THIS_MATTERS: 'contingency-why-this-matters',
19
19
  CREATION_REASONING: 'contingency-creation-reasoning',
20
20
  RESOLVED_REASON: 'contingency-resolved-reason',
21
+ RELATED_CASE_ID: 'contingency-related-case-id',
22
+ RELATED_CLUSTER_ID: 'contingency-related-cluster-id',
21
23
  STEP_PRIORITY: 'contingency-step-priority',
22
24
  ACTION_REQUESTED: 'contingency-action-requested',
23
25
  FIRES_AFTER_SILENCE_MINUTES: 'fires-after-silence-minutes',
@@ -89,6 +91,8 @@ function carePlanPeriod(carePlan) {
89
91
 
90
92
  function carePlanExtensions(carePlan) {
91
93
  const out = [extension(EXT.STATUS, { valueString: carePlan.status })];
94
+ if (carePlan.relatedCaseId) out.push(extension(EXT.RELATED_CASE_ID, { valueString: carePlan.relatedCaseId }));
95
+ if (carePlan.relatedClusterId) out.push(extension(EXT.RELATED_CLUSTER_ID, { valueString: carePlan.relatedClusterId }));
92
96
  if (carePlan.awaitingCharacterization) out.push(extension(EXT.AWAITING_CHARACTERIZATION, { valueString: carePlan.awaitingCharacterization }));
93
97
  if (carePlan.whyThisMatters) out.push(extension(EXT.WHY_THIS_MATTERS, { valueString: carePlan.whyThisMatters }));
94
98
  if (carePlan.creationReasoning) out.push(extension(EXT.CREATION_REASONING, { valueString: carePlan.creationReasoning }));
@@ -148,8 +152,8 @@ function reconstruct(plan, requests, patientId) {
148
152
  carePlan: {
149
153
  planId,
150
154
  patientId,
151
- relatedCaseId: refToId(address && address.reference),
152
- relatedClusterId: refToId(plan.supportingInfo && plan.supportingInfo[0] && plan.supportingInfo[0].reference),
155
+ relatedCaseId: referencedId(plan, EXT.RELATED_CASE_ID, address?.reference),
156
+ relatedClusterId: referencedId(plan, EXT.RELATED_CLUSTER_ID, plan.supportingInfo?.[0]?.reference),
153
157
  concern: plan.description || '',
154
158
  awaitingCharacterization: ext[EXT.AWAITING_CHARACTERIZATION] || '',
155
159
  whyThisMatters: ext[EXT.WHY_THIS_MATTERS] || '',
@@ -2,9 +2,9 @@ const { ZodError } = require('zod');
2
2
 
3
3
  const { getDeviceId } = require('../config/fhirConfig');
4
4
  const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
5
- const { identifier, patientReference, reference, recipientReference, codeableConcept, extension, notes, toIso } = require('../helpers/elementHelper');
5
+ const { patientReference, reference, recipientReference, codeableConcept, extension, notes, toIso } = require('../helpers/elementHelper');
6
6
  const { baseResource, provenance } = require('../helpers/resourceHelper');
7
- const { identifierValue, refToId, extValues } = require('../helpers/fhirReadHelper');
7
+ const { identifierValue, refToId, extValues, referencedId } = require('../helpers/fhirReadHelper');
8
8
  const { INTERVENTION_SLUG, INTERVENTION_KIND_SLUG, INTERVENTION_OUTCOME_SLUG } = require('../constants/projectionSlugs');
9
9
  const { Intervention } = require('../../shared/dtos/Intervention');
10
10
  const { logger } = require('../../utils/logger');
@@ -13,6 +13,7 @@ const ACTIVITY_TEXT = 'intervention-record';
13
13
  const PROCEDURE_KINDS = new Set(['otc', 'self_care', 'other']);
14
14
 
15
15
  const EXT = {
16
+ CASE_ID: 'intervention-case-id',
16
17
  RATIONALE: 'intervention-rationale',
17
18
  OUTCOME_NOTES: 'intervention-outcome-notes',
18
19
  OUTCOME: 'intervention-outcome',
@@ -78,9 +79,7 @@ function communicationResource(intervention) {
78
79
 
79
80
  function taskResource(intervention) {
80
81
  const payload = withNote({
81
- resourceType: 'Task',
82
- id: fhirId(intervention.interventionId),
83
- identifier: [identifier(INTERVENTION_SLUG, intervention.interventionId)],
82
+ ...baseResource('Task', intervention.interventionId, INTERVENTION_SLUG),
84
83
  status: intervention.outcome === 'pending' ? 'requested' : 'completed',
85
84
  intent: 'order',
86
85
  code: codeableConcept(intervention.interventionLabel, {
@@ -122,6 +121,7 @@ function communicationRequestResource(intervention) {
122
121
 
123
122
  function interventionExtensions(intervention, { withRecipient = false } = {}) {
124
123
  const out = [];
124
+ if (intervention.caseId) out.push(extension(EXT.CASE_ID, { valueString: intervention.caseId }));
125
125
  if (intervention.rationale) out.push(extension(EXT.RATIONALE, { valueString: intervention.rationale }));
126
126
  if (intervention.outcomeNotes) out.push(extension(EXT.OUTCOME_NOTES, { valueString: intervention.outcomeNotes }));
127
127
  if (intervention.outcome) out.push(extension(EXT.OUTCOME, { valueString: intervention.outcome }));
@@ -144,7 +144,7 @@ function reconstruct(resource, patientId) {
144
144
  const base = {
145
145
  interventionId: identifierValue(resource, INTERVENTION_SLUG),
146
146
  patientId,
147
- caseId: refToId(reasonRef && reasonRef.reference),
147
+ caseId: referencedId(resource, EXT.CASE_ID, reasonRef?.reference),
148
148
  rationale: extValues(resource, EXT.RATIONALE)[0] ?? null,
149
149
  outcomeNotes: extValues(resource, EXT.OUTCOME_NOTES)[0] ?? null,
150
150
  outcomeExt: extValues(resource, EXT.OUTCOME)[0] ?? null,
@@ -1,5 +1,4 @@
1
1
  const { registerProjector } = require('../services/fhirService');
2
- const { projectClinicalMention, PROJECTOR_NAME: MENTION_PROJECTOR_NAME } = require('./clinicalMentionProjection');
3
2
  const { projectDispatchedEscalation, PROJECTOR_NAME: ESCALATION_PROJECTOR_NAME } = require('./dispatchedEscalationProjection');
4
3
  const { projectReminder, PROJECTOR_NAME: REMINDER_PROJECTOR_NAME } = require('./reminderProjection');
5
4
  const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
@@ -9,7 +8,6 @@ const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME }
9
8
  const { projectPalliativeCarePlan, PROJECTOR_NAME: PALLIATIVE_CAREPLAN_PROJECTOR_NAME } = require('./palliativeCarePlanProjection');
10
9
 
11
10
  const PROJECTORS = [
12
- { name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
13
11
  { name: ESCALATION_PROJECTOR_NAME, project: projectDispatchedEscalation },
14
12
  { name: REMINDER_PROJECTOR_NAME, project: projectReminder },
15
13
  { name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
@@ -3,7 +3,7 @@ const { ZodError } = require('zod');
3
3
  const { getDeviceId } = require('../config/fhirConfig');
4
4
  const { reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
5
5
  const { baseResource, provenance } = require('../helpers/resourceHelper');
6
- const { extMap, extValues, identifierValue, refToId } = require('../helpers/fhirReadHelper');
6
+ const { extMap, extValues, identifierValue, refToId, referencedId } = require('../helpers/fhirReadHelper');
7
7
  const { ROUTING_ASSESSMENT_SLUG, ROUTING_DISPOSITION_SLUG } = require('../constants/projectionSlugs');
8
8
  const { ROUTING_EXT } = require('../constants/extensionSlugs');
9
9
  const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
@@ -53,20 +53,17 @@ const DISPOSITION_CODECS = {
53
53
  },
54
54
  task: {
55
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
- },
56
+ build: (d) => ({
57
+ ...baseResource('Task', d.dispositionId, ROUTING_DISPOSITION_SLUG),
58
+ status: 'requested',
59
+ intent: 'order',
60
+ code: codeableConcept(d.primaryAction),
61
+ description: d.primaryReason,
62
+ for: patientReference(d.patientId),
63
+ authoredOn: toIso(d.authoredAt),
64
+ requester: reference('Device', getDeviceId()),
65
+ ...(d.primaryCaseId ? { reason: caseReason(d.primaryCaseId) } : {}),
66
+ }),
70
67
  actionOf: (r) => ccText(r.code),
71
68
  reasonOf: (r) => r.description,
72
69
  authoredOf: (r) => r.authoredOn,
@@ -171,6 +168,7 @@ function assessmentExtensions(assessment, { decisionId, committedAt, disposition
171
168
  if (disposition.primaryCaseId) {
172
169
  out.push(extension(ROUTING_EXT.PRIMARY_CASE_ID, { valueString: disposition.primaryCaseId }));
173
170
  }
171
+ if (assessment.previousId) out.push(extension(ROUTING_EXT.PREVIOUS_ID, { valueString: assessment.previousId }));
174
172
  for (const deferred of disposition.deferredItems) {
175
173
  out.push(
176
174
  extension(ROUTING_EXT.DEFERRED_ITEM, { valueString: deferred.item }),
@@ -240,13 +238,13 @@ function reconstructAssessment(resource, patientId, turnId) {
240
238
  patientId,
241
239
  turnId,
242
240
  performedAt: resource.effectiveDateTime,
243
- previousId: refToId(resource.previous?.reference),
241
+ previousId: referencedId(resource, ROUTING_EXT.PREVIOUS_ID, resource.previous?.reference),
244
242
  summary: resource.summary || '',
245
243
  notes: (resource.note || []).map((note) => note.text),
246
244
  findings: (resource.finding || []).map((finding) => {
247
245
  const findingExt = extMap(finding);
248
246
  return {
249
- caseId: findingExt[ROUTING_EXT.FINDING_CASE_ID] || refToId(finding.item?.reference),
247
+ caseId: referencedId(finding, ROUTING_EXT.FINDING_CASE_ID, finding.item?.reference),
250
248
  clinicalReasoning: findingExt[ROUTING_EXT.FINDING_REASONING] || '',
251
249
  decisionAction: findingExt[ROUTING_EXT.FINDING_ACTION] || '',
252
250
  };
@@ -3,7 +3,7 @@ const { ZodError } = require('zod');
3
3
  const { getSystems } = require('../config/fhirConfig');
4
4
  const { codeSystemUrl } = require('../helpers/fhirHelper');
5
5
  const { reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
6
- const { baseResource, provenance, observationCategory } = require('../helpers/resourceHelper');
6
+ const { baseResource, provenance, problemListCategory, observationCategory } = require('../helpers/resourceHelper');
7
7
  const { extMap, extValues, identifierValue } = require('../helpers/fhirReadHelper');
8
8
  const { SYMPTOM_CASE_SLUG, SYMPTOM_ASSESSMENT_SLUG, SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
9
9
  const { SYMPTOM_CASE_EXT, ASSESSMENT_EXT, GRADE_EXT } = require('../constants/extensionSlugs');
@@ -76,7 +76,7 @@ function conditionResource(condition, verification, headAssessmentId) {
76
76
  clinicalStatus: codeableConcept(clinicalStatusCode, { code: clinicalStatusCode, system: systems.conditionClinical }),
77
77
  verificationStatus: codeableConcept(verificationCode, { code: verificationCode, system: systems.conditionVerStatus }),
78
78
  category: [
79
- codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory }),
79
+ problemListCategory(),
80
80
  codeableConcept('PRO-CTCAE patient-reported symptom (agent-tracked case)', { code: 'proctcae-symptom', system: codeSystemUrl('condition-category') }),
81
81
  ],
82
82
  code,
@@ -1,35 +1,13 @@
1
1
  const { getSystems } = require('../config/fhirConfig');
2
2
  const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
3
3
  const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
4
+ const { problemListCategory } = require('../helpers/resourceHelper');
4
5
 
5
6
  class Condition {
6
7
  constructor(payload) {
7
8
  Object.assign(this, payload);
8
9
  }
9
10
 
10
- static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
11
- const assertion = fact.assertion;
12
- const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
13
- ? provenance.verbatimQuotes.map((text) => ({ text }))
14
- : null;
15
- const verification = assertion.hedged ? 'unconfirmed' : 'provisional';
16
- const systems = getSystems();
17
- const payload = {
18
- resourceType: 'Condition',
19
- id: fhirId(fact.mentionId),
20
- identifier: [identifier(slug, fact.mentionId)],
21
- clinicalStatus: codeableConcept('active', { code: 'active', system: systems.conditionClinical }),
22
- verificationStatus: codeableConcept(verification, { code: verification, system: systems.conditionVerStatus }),
23
- category: [codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory })],
24
- code,
25
- subject: patientReference(fact.patientId),
26
- recordedDate: new Date(provenance.recordedAt).toISOString(),
27
- extension: extensions,
28
- };
29
- if (notes) payload.note = notes;
30
- return new Condition(payload);
31
- }
32
-
33
11
  static fromTriageProjection({ sym, code }) {
34
12
  const caseId = `${sym.patientId}-${sym.term}`;
35
13
  const systems = getSystems();
@@ -40,7 +18,7 @@ class Condition {
40
18
  clinicalStatus: codeableConcept('active', { code: 'active', system: systems.conditionClinical }),
41
19
  verificationStatus: codeableConcept('provisional', { code: 'provisional', system: systems.conditionVerStatus }),
42
20
  category: [
43
- codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory }),
21
+ problemListCategory(),
44
22
  codeableConcept('PRO-CTCAE patient-reported symptom (triage-tracked case)', { code: 'proctcae-symptom', system: codeSystemUrl('condition-category') }),
45
23
  ],
46
24
  code,
@@ -1,53 +1,13 @@
1
- const { getSystems } = require('../config/fhirConfig');
2
1
  const { fhirId } = require('../helpers/fhirHelper');
3
2
  const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
4
3
  const { observationCategory } = require('../helpers/resourceHelper');
5
4
  const { GRADE_EXT } = require('../constants/extensionSlugs');
6
5
 
7
- const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
8
-
9
- const CATEGORY_TO_OBSERVATION_CATEGORY = {
10
- symptom: 'survey',
11
- lab_value: 'laboratory',
12
- emotion: 'survey',
13
- indication: 'exam',
14
- diagnosis: 'exam',
15
- };
16
-
17
6
  class Observation {
18
7
  constructor(payload) {
19
8
  Object.assign(this, payload);
20
9
  }
21
10
 
22
- static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
23
- const assertion = fact.assertion;
24
- const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
25
- ? provenance.verbatimQuotes.map((text) => ({ text }))
26
- : null;
27
- const categoryCode = CATEGORY_TO_OBSERVATION_CATEGORY[fact.category] || 'exam';
28
- const payload = {
29
- resourceType: 'Observation',
30
- id: fhirId(fact.mentionId),
31
- identifier: [identifier(slug, fact.mentionId)],
32
- status: 'preliminary',
33
- category: [observationCategory(categoryCode)],
34
- code,
35
- subject: patientReference(fact.patientId),
36
- performer: [patientReference(fact.patientId)],
37
- effectiveDateTime: new Date(fact.reportedAt).toISOString(),
38
- issued: new Date(provenance.recordedAt).toISOString(),
39
- extension: extensions,
40
- };
41
- if (assertion.negated) {
42
- payload.interpretation = [codeableConcept('Negative', { code: 'NEG', system: getSystems().observationInterpretation, display: 'Negative' })];
43
- payload.valueCodeableConcept = codeableConcept('Ausente (negado por el paciente)');
44
- }
45
- if (notes) payload.note = notes;
46
- const components = observedFieldComponents(fact.observedFields);
47
- if (components.length) payload.component = components;
48
- return new Observation(payload);
49
- }
50
-
51
11
  static fromTriageProjection({ sym, code, aeCategoryConcept, components, gradeConcept, method, deviceId }) {
52
12
  const sid = String(sym.symptomId);
53
13
  const payload = {
@@ -95,28 +55,4 @@ class Observation {
95
55
  }
96
56
  }
97
57
 
98
- function observedFieldComponents(observedFields) {
99
- const components = [];
100
- for (const [key, raw] of Object.entries(observedFields || {})) {
101
- const text = stringifyObserved(raw);
102
- if (!text) continue;
103
- components.push({
104
- code: codeableConcept(key, { code: key, system: getSystems()[OBSERVED_FIELD_SYSTEM_KEY] }),
105
- valueString: text,
106
- });
107
- }
108
- return components;
109
- }
110
-
111
- function stringifyObserved(value) {
112
- if (value === null || value === undefined) return '';
113
- if (Array.isArray(value)) {
114
- return value.filter((v) => v !== null && v !== undefined).map((v) => String(v).trim()).filter(Boolean).join('; ');
115
- }
116
- if (typeof value === 'object') {
117
- return Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim()).map(([k, v]) => `${k}: ${v}`).join('; ');
118
- }
119
- return String(value).trim();
120
- }
121
-
122
58
  module.exports = { Observation };
@@ -1,29 +1,12 @@
1
1
  const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
2
2
  const { fhirId } = require('../helpers/fhirHelper');
3
- const { extension, reference, codeableConcept, toIso } = require('../helpers/elementHelper');
3
+ const { reference, codeableConcept, toIso } = require('../helpers/elementHelper');
4
4
 
5
5
  class Provenance {
6
6
  constructor(payload) {
7
7
  Object.assign(this, payload);
8
8
  }
9
9
 
10
- static fromMentionProjection({ target, provenance, activityText }) {
11
- const payload = {
12
- resourceType: 'Provenance',
13
- id: fhirId(`${provenance.mentionId}-prov`),
14
- target: [{ reference: `${target.resourceType}/${target.id}` }],
15
- recorded: new Date(provenance.recordedAt).toISOString(),
16
- agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
17
- activity: codeableConcept(activityText),
18
- extension: [
19
- extension('mention-turn-id', { valueString: provenance.turnId }),
20
- extension('mention-source', { valueString: provenance.source }),
21
- extension('mention-extractor-confidence', { valueDecimal: provenance.extractorConfidence }),
22
- ],
23
- };
24
- return new Provenance(payload);
25
- }
26
-
27
10
  static fromDispatchedEscalation({ target, recordedAt, activityText }) {
28
11
  return new Provenance({
29
12
  resourceType: 'Provenance',
@@ -1,5 +1,5 @@
1
- const { PROJECTOR_NAME } = require('../projections/clinicalMentionProjection');
2
- const { project, storeResources } = require('./fhirService');
1
+ const { toFhir } = require('../projections/clinicalMentionProjection');
2
+ const { storeResources } = require('./fhirService');
3
3
 
4
4
  async function storeClinicalMentions({ patientId, mentions }) {
5
5
  for (const mention of mentions) {
@@ -7,9 +7,7 @@ async function storeClinicalMentions({ patientId, mentions }) {
7
7
  throw new Error(`storeClinicalMentions patientId mismatch: expected ${patientId}`);
8
8
  }
9
9
  }
10
- const bundle = project(mentions.map((mention) => ({ name: PROJECTOR_NAME, aggregate: mention })));
11
- const resources = bundle.entry.map((entry) => entry.resource);
12
- const { stored } = await storeResources(resources);
10
+ const { stored } = await storeResources(mentions.flatMap(toFhir));
13
11
  return { stored: stored.length, ids: stored, patientId };
14
12
  }
15
13
 
@@ -15,6 +15,17 @@ const parseList = (value) =>
15
15
  .map((entry) => entry.trim())
16
16
  .filter(Boolean);
17
17
 
18
+ function stringifyValue(value) {
19
+ if (value === null || value === undefined) return '';
20
+ if (Array.isArray(value)) {
21
+ return value.filter((v) => v !== null && v !== undefined).map((v) => String(v).trim()).filter(Boolean).join('; ');
22
+ }
23
+ if (typeof value === 'object') {
24
+ return Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim()).map(([k, v]) => `${k}: ${v}`).join('; ');
25
+ }
26
+ return String(value).trim();
27
+ }
28
+
18
29
  function termKey(name) {
19
30
  return String(name || '')
20
31
  .trim()
@@ -29,5 +40,6 @@ module.exports = {
29
40
  lookupValue,
30
41
  integerId,
31
42
  parseList,
43
+ stringifyValue,
32
44
  termKey,
33
45
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.12.2",
3
+ "version": "5.12.4",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -1,28 +0,0 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const { identifier, patientReference } = require('../helpers/elementHelper');
3
-
4
- class Appointment {
5
- constructor(payload) {
6
- Object.assign(this, payload);
7
- }
8
-
9
- static fromMentionProjection({ fact, provenance, extensions, slug }) {
10
- const assertion = fact.assertion;
11
- const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
12
- ? provenance.verbatimQuotes.map((text) => ({ text }))
13
- : null;
14
- const payload = {
15
- resourceType: 'Appointment',
16
- id: fhirId(fact.mentionId),
17
- identifier: [identifier(slug, fact.mentionId)],
18
- status: assertion.negated ? 'cancelled' : 'proposed',
19
- description: fact.canonicalTerm,
20
- participant: [{ actor: patientReference(fact.patientId), status: 'accepted' }],
21
- extension: extensions,
22
- };
23
- if (notes) payload.note = notes;
24
- return new Appointment(payload);
25
- }
26
- }
27
-
28
- module.exports = { Appointment };
@@ -1,42 +0,0 @@
1
- const { getSystems } = require('../config/fhirConfig');
2
- const { fhirId } = require('../helpers/fhirHelper');
3
- const { identifier, patientReference, codeableConcept } = require('../helpers/elementHelper');
4
-
5
- class MedicationStatement {
6
- constructor(payload) {
7
- Object.assign(this, payload);
8
- }
9
-
10
- static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
11
- const assertion = fact.assertion;
12
- const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
13
- ? provenance.verbatimQuotes.map((text) => ({ text }))
14
- : null;
15
- const payload = {
16
- resourceType: 'MedicationStatement',
17
- id: fhirId(fact.mentionId),
18
- identifier: [identifier(slug, fact.mentionId)],
19
- status: 'recorded',
20
- medication: { concept: code },
21
- subject: patientReference(fact.patientId),
22
- effectiveDateTime: new Date(fact.reportedAt).toISOString(),
23
- extension: extensions,
24
- };
25
- const adherence = adherenceFor(assertion);
26
- if (adherence) {
27
- payload.adherence = {
28
- code: codeableConcept(adherence, { code: adherence, system: getSystems().medicationStatementAdherence }),
29
- };
30
- }
31
- if (notes) payload.note = notes;
32
- return new MedicationStatement(payload);
33
- }
34
- }
35
-
36
- function adherenceFor(assertion) {
37
- if (assertion.negated) return 'not-taking';
38
- if (assertion.temporal === 'historical') return 'stopped';
39
- return null;
40
- }
41
-
42
- module.exports = { MedicationStatement };
@@ -1,30 +0,0 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const { identifier, patientReference } = require('../helpers/elementHelper');
3
-
4
- class Procedure {
5
- constructor(payload) {
6
- Object.assign(this, payload);
7
- }
8
-
9
- static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
10
- const assertion = fact.assertion;
11
- const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
12
- ? provenance.verbatimQuotes.map((text) => ({ text }))
13
- : null;
14
- const payload = {
15
- resourceType: 'Procedure',
16
- id: fhirId(fact.mentionId),
17
- identifier: [identifier(slug, fact.mentionId)],
18
- status: assertion.negated ? 'not-done' : 'completed',
19
- code,
20
- subject: patientReference(fact.patientId),
21
- occurrenceDateTime: new Date(fact.reportedAt).toISOString(),
22
- recorder: patientReference(fact.patientId),
23
- extension: extensions,
24
- };
25
- if (notes) payload.note = notes;
26
- return new Procedure(payload);
27
- }
28
- }
29
-
30
- module.exports = { Procedure };