@peopl-health/nexus 5.13.0-dev.1145 → 5.13.0-dev.1148

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,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
 
@@ -22,7 +22,13 @@ function provenance({ provenanceId, targetRefs, recordedAt, activityText }) {
22
22
  };
23
23
  }
24
24
 
25
+ function observationCategory(code) {
26
+ const label = code.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
27
+ return codeableConcept(label, { code, system: getSystems().observationCategory, display: label });
28
+ }
29
+
25
30
  module.exports = {
26
31
  baseResource,
27
32
  provenance,
33
+ observationCategory,
28
34
  };
@@ -23,6 +23,10 @@ const envelopeSchema = new mongoose.Schema(
23
23
 
24
24
  envelopeSchema.index({ 'index.patient': 1, 'index.date': -1 });
25
25
  envelopeSchema.index({ 'index.patient': 1, 'index.symptomTerm': 1, 'index.date': -1 });
26
+ envelopeSchema.index(
27
+ { 'index.patient': 1, 'resource.identifier.system': 1, 'index.date': -1 },
28
+ { partialFilterExpression: { 'meta.deleted': false } },
29
+ );
26
30
  envelopeSchema.index({ 'index.reference': 1 }, { partialFilterExpression: { 'index.reference': { $type: 'string' } } });
27
31
  envelopeSchema.index({ 'resource.status': 1 }, { sparse: true });
28
32
 
@@ -2,7 +2,6 @@ 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');
@@ -15,7 +14,6 @@ const PROJECTORS = [
15
14
  { name: MENTION_PROJECTOR_NAME, project: projectClinicalMention },
16
15
  { name: ESCALATION_PROJECTOR_NAME, project: projectDispatchedEscalation },
17
16
  { name: REMINDER_PROJECTOR_NAME, project: projectReminder },
18
- { name: SYMPTOM_CASE_PROJECTOR_NAME, project: projectSymptomCase },
19
17
  { name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
20
18
  { name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
21
19
  { name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
@@ -1,65 +1,289 @@
1
- const { fhirId } = require('../helpers/fhirHelper');
2
- const { ClinicalImpression } = require('../resources/ClinicalImpression');
3
- const { Condition } = require('../resources/Condition');
4
- const { Observation } = require('../resources/Observation');
5
- const { Provenance } = require('../resources/Provenance');
1
+ const { ZodError } = require('zod');
2
+
3
+ const { getSystems } = require('../config/fhirConfig');
4
+ const { codeSystemUrl } = require('../helpers/fhirHelper');
5
+ const { reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
6
+ const { baseResource, provenance, observationCategory } = require('../helpers/resourceHelper');
7
+ const { extMap, extValues, identifierValue } = require('../helpers/fhirReadHelper');
8
+ const { SYMPTOM_CASE_SLUG, SYMPTOM_ASSESSMENT_SLUG, SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
9
+ const { SYMPTOM_CASE_EXT, ASSESSMENT_EXT, GRADE_EXT } = require('../constants/extensionSlugs');
10
+ const { ManagedSymptom, RESOLUTION_CLOSE_REASONS } = require('../../shared/dtos/ManagedSymptom');
11
+ const { logger } = require('../../utils/logger');
6
12
 
7
- const PROJECTOR_NAME = 'symptomCase';
8
13
  const ACTIVITY_TEXT = 'symptom-management';
9
14
  const PLACEHOLDER_DECISION_POSTURE = 'pending_initial_assessment';
15
+ const PROVENANCE_PARTICIPANT_TYPE_SYSTEM = 'http://terminology.hl7.org/CodeSystem/provenance-participant-type';
10
16
 
11
- function assessmentResources(assessment, dropPreviousTo) {
12
- const grade = assessment.grade;
13
- const hasGradeSignal = grade.bestEstimate !== null
14
- || grade.possibleRange !== null
15
- || Boolean(grade.confidence)
16
- || Boolean(grade.reasoning)
17
- || Boolean(assessment.gradeThresholdClause);
18
- const out = [ClinicalImpression.fromSymptomAssessment({ assessment, dropPreviousTo, hasGradeSignal })];
19
- if (hasGradeSignal) out.push(Observation.fromSymptomGrade({ assessment }));
20
- return out;
21
- }
17
+ const CLINICAL_STATUS_TO_FHIR = { resolved: 'resolved', inactive: 'inactive' };
18
+ const VALID_VERIFICATION = new Set(['confirmed', 'provisional', 'unconfirmed']);
22
19
 
23
- function projectSymptomCase(managedSymptom) {
20
+ function toFhir(managedSymptom) {
24
21
  const { condition, latestAssessment, priorAssessments } = managedSymptom;
25
22
  const allAssessments = [latestAssessment, ...priorAssessments];
26
- const placeholderIds = new Set(
27
- allAssessments.filter((a) => a.decisionPosture === PLACEHOLDER_DECISION_POSTURE).map((a) => a.assessmentId),
28
- );
29
-
30
- const fhirCondition = Condition.fromSymptomCase({
31
- condition,
32
- verification: latestAssessment.gradeVerification,
33
- headAssessmentId: latestAssessment.assessmentId,
34
- });
35
-
36
- const out = [fhirCondition, ...assessmentResources(latestAssessment, placeholderIds)];
37
- const provenanceTargets = [
38
- { reference: `Condition/${fhirId(condition.conditionId)}` },
39
- { reference: `ClinicalImpression/${fhirId(latestAssessment.assessmentId)}` },
40
- ];
41
- out.push(Provenance.fromSymptomCase({
42
- provenanceId: `${condition.conditionId}-prov`,
43
- targetRefs: provenanceTargets,
44
- recordedAt: latestAssessment.performedAt,
45
- activityText: ACTIVITY_TEXT,
46
- }));
23
+ const placeholderIds = new Set(allAssessments.filter((a) => a.decisionPosture === PLACEHOLDER_DECISION_POSTURE).map((a) => a.assessmentId));
47
24
 
25
+ const out = [
26
+ conditionResource(condition, latestAssessment.gradeVerification, latestAssessment.assessmentId),
27
+ ...assessmentResources(latestAssessment, placeholderIds),
28
+ caseProvenance(condition.conditionId, latestAssessment.performedAt, [
29
+ reference('Condition', condition.conditionId),
30
+ reference('ClinicalImpression', latestAssessment.assessmentId),
31
+ ]),
32
+ ];
48
33
  for (const prior of priorAssessments) {
49
34
  if (placeholderIds.has(prior.assessmentId)) continue;
50
- out.push(...assessmentResources(prior, placeholderIds));
51
- out.push(Provenance.fromSymptomCase({
52
- provenanceId: `${prior.assessmentId}-prov`,
53
- targetRefs: [{ reference: `ClinicalImpression/${fhirId(prior.assessmentId)}` }],
54
- recordedAt: prior.performedAt,
55
- activityText: ACTIVITY_TEXT,
56
- }));
35
+ out.push(
36
+ ...assessmentResources(prior, placeholderIds),
37
+ caseProvenance(prior.assessmentId, prior.performedAt, [reference('ClinicalImpression', prior.assessmentId)]),
38
+ );
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function caseProvenance(anchorId, recordedAt, targetRefs) {
44
+ return provenance({ provenanceId: `${anchorId}-prov`, targetRefs, recordedAt, activityText: ACTIVITY_TEXT });
45
+ }
46
+
47
+ function assessmentResources(assessment, placeholderIds) {
48
+ const grade = assessment.grade;
49
+ const hasGradeSignal = grade.bestEstimate !== null || grade.possibleRange !== null
50
+ || Boolean(grade.confidence) || Boolean(grade.reasoning) || Boolean(assessment.gradeThresholdClause);
51
+ const out = [impressionResource(assessment, placeholderIds, hasGradeSignal)];
52
+ if (hasGradeSignal) out.push(gradeResource(assessment));
53
+ return out;
54
+ }
55
+
56
+ function fhirClinicalStatusFor(condition) {
57
+ const status = condition.clinicalStatus;
58
+ if (status === 'characterizing' || status === 'monitoring') {
59
+ return condition.recurrenceCount > 0 ? 'recurrence' : 'active';
60
+ }
61
+ if (status === 'closed') {
62
+ return RESOLUTION_CLOSE_REASONS.has((condition.closeReason || '').trim().toLowerCase()) ? 'resolved' : 'inactive';
63
+ }
64
+ return CLINICAL_STATUS_TO_FHIR[status];
65
+ }
66
+
67
+ function conditionResource(condition, verification, headAssessmentId) {
68
+ const systems = getSystems();
69
+ const clinicalStatusCode = fhirClinicalStatusFor(condition);
70
+ const verificationCode = VALID_VERIFICATION.has(verification) ? verification : 'provisional';
71
+ const code = condition.ctcaeTerm && condition.ctcaeTerm !== 'other'
72
+ ? codeableConcept(condition.ctcaeTerm, { code: condition.ctcaeTerm, system: codeSystemUrl('pro-ctcae'), display: condition.ctcaeTerm })
73
+ : codeableConcept(condition.ctcaeTerm || 'other');
74
+ const payload = {
75
+ ...baseResource('Condition', condition.conditionId, SYMPTOM_CASE_SLUG, condition.patientId),
76
+ clinicalStatus: codeableConcept(clinicalStatusCode, { code: clinicalStatusCode, system: systems.conditionClinical }),
77
+ verificationStatus: codeableConcept(verificationCode, { code: verificationCode, system: systems.conditionVerStatus }),
78
+ category: [
79
+ codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory }),
80
+ codeableConcept('PRO-CTCAE patient-reported symptom (agent-tracked case)', { code: 'proctcae-symptom', system: codeSystemUrl('condition-category') }),
81
+ ],
82
+ code,
83
+ participant: [{
84
+ function: codeableConcept('Informant', { code: 'informant', system: PROVENANCE_PARTICIPANT_TYPE_SYSTEM }),
85
+ actor: patientReference(condition.patientId),
86
+ }],
87
+ onsetDateTime: toIso(condition.onsetAt),
88
+ extension: conditionExtensions(condition, clinicalStatusCode),
89
+ };
90
+ if (condition.abatementAt && clinicalStatusCode === 'resolved') payload.abatementDateTime = toIso(condition.abatementAt);
91
+ if (condition.lastTransitionReason) payload.note = [{ text: condition.lastTransitionReason }];
92
+ const evidence = [
93
+ ...condition.evidenceEpisodeIds.map((id) => ({ reference: reference('Observation', id) })),
94
+ ...condition.evidenceMentionIds.map((id) => ({ reference: reference('Observation', id) })),
95
+ ];
96
+ if (evidence.length) payload.evidence = evidence;
97
+ if (headAssessmentId) payload.stage = [{ assessment: [reference('ClinicalImpression', headAssessmentId)] }];
98
+ return payload;
99
+ }
100
+
101
+ function conditionExtensions(condition, clinicalStatusCode) {
102
+ const out = [extension(SYMPTOM_CASE_EXT.STATUS, { valueString: condition.clinicalStatus })];
103
+ if (condition.abatementAt && clinicalStatusCode !== 'resolved') {
104
+ out.push(extension(SYMPTOM_CASE_EXT.LEGACY_ABATEMENT, { valueDateTime: toIso(condition.abatementAt) }));
105
+ }
106
+ if (condition.closeReason) {
107
+ out.push(extension(SYMPTOM_CASE_EXT.CLOSE_REASON, { valueString: condition.closeReason }));
108
+ if (clinicalStatusCode === 'inactive' && condition.closeReason === 'stale_no_activity') {
109
+ out.push(extension('presumed-inactive-by-gap', { valueBoolean: true }));
110
+ }
111
+ }
112
+ if (condition.temporality) out.push(extension(SYMPTOM_CASE_EXT.TEMPORALITY, { valueString: condition.temporality }));
113
+ if (condition.currentEpisodeId) out.push(extension(SYMPTOM_CASE_EXT.CURRENT_EPISODE_ID, { valueString: condition.currentEpisodeId }));
114
+ for (const id of condition.evidenceEpisodeIds) out.push(extension(SYMPTOM_CASE_EXT.EVIDENCE_EPISODE_IDS, { valueString: id }));
115
+ for (const id of condition.evidenceMentionIds) out.push(extension(SYMPTOM_CASE_EXT.EVIDENCE_MENTION_IDS, { valueString: id }));
116
+ out.push(extension(SYMPTOM_CASE_EXT.RECURRENCE_COUNT, { valueInteger: condition.recurrenceCount }));
117
+ if (condition.reactivatedAt) out.push(extension(SYMPTOM_CASE_EXT.REACTIVATED_AT, { valueDateTime: toIso(condition.reactivatedAt) }));
118
+ return out;
119
+ }
120
+
121
+ function impressionResource(assessment, dropPreviousTo, hasGradeSignal) {
122
+ const payload = {
123
+ ...baseResource('ClinicalImpression', assessment.assessmentId, SYMPTOM_ASSESSMENT_SLUG, assessment.patientId),
124
+ status: 'completed',
125
+ effectiveDateTime: toIso(assessment.performedAt),
126
+ problem: [reference('Condition', assessment.conditionId)],
127
+ extension: assessmentExtensions(assessment),
128
+ };
129
+ if (hasGradeSignal) payload.finding = [{ item: { reference: reference('Observation', `${assessment.assessmentId}-grade`) } }];
130
+ if (assessment.problemRepresentation) payload.summary = assessment.problemRepresentation;
131
+ if (assessment.previousId && !dropPreviousTo?.has(assessment.previousId)) {
132
+ payload.previous = reference('ClinicalImpression', assessment.previousId);
57
133
  }
134
+ return payload;
135
+ }
136
+
137
+ function assessmentExtensions(assessment) {
138
+ const out = [];
139
+ if (assessment.trajectory) out.push(extension(ASSESSMENT_EXT.TRAJECTORY, { valueString: assessment.trajectory }));
140
+ if (assessment.decisionPosture) out.push(extension(ASSESSMENT_EXT.DECISION_POSTURE, { valueString: assessment.decisionPosture }));
141
+ if (assessment.postureReason) out.push(extension(ASSESSMENT_EXT.POSTURE_REASON, { valueString: assessment.postureReason }));
142
+ if (assessment.reasoning) out.push(extension(ASSESSMENT_EXT.REASONING, { valueString: assessment.reasoning }));
143
+ return out;
144
+ }
145
+
146
+ function gradeResource(assessment) {
147
+ const grade = assessment.grade;
148
+ const gradeId = `${assessment.assessmentId}-grade`;
149
+ const payload = {
150
+ ...baseResource('Observation', gradeId, SYMPTOM_GRADE_SLUG, assessment.patientId),
151
+ status: 'final',
152
+ category: [observationCategory('survey')],
153
+ code: codeableConcept('CTCAE severity grade'),
154
+ performer: [patientReference(assessment.patientId)],
155
+ effectiveDateTime: toIso(assessment.performedAt),
156
+ };
157
+ if (grade.bestEstimate !== null) payload.valueInteger = grade.bestEstimate;
158
+ const ext = gradeExtensions(assessment);
159
+ if (ext.length) payload.extension = ext;
160
+ return payload;
161
+ }
58
162
 
163
+ function gradeExtensions(assessment) {
164
+ const grade = assessment.grade;
165
+ const out = [];
166
+ if (grade.confidence !== null) out.push(extension(GRADE_EXT.CONFIDENCE, { valueString: grade.confidence }));
167
+ if (grade.possibleRange !== null) out.push(extension(GRADE_EXT.RANGE, { valueString: `${grade.possibleRange[0]}-${grade.possibleRange[1]}` }));
168
+ if (grade.reasoning) out.push(extension(GRADE_EXT.REASONING, { valueString: grade.reasoning }));
169
+ if (assessment.gradeVerification) out.push(extension(GRADE_EXT.VERIFICATION, { valueString: assessment.gradeVerification }));
170
+ if (assessment.gradeThresholdClause) out.push(extension(GRADE_EXT.THRESHOLD_CLAUSE, { valueString: assessment.gradeThresholdClause }));
59
171
  return out;
60
172
  }
61
173
 
174
+ function fromFhir(resources, { patientId }) {
175
+ const byReference = new Map();
176
+ for (const resource of resources) {
177
+ if (resource.resourceType === 'ClinicalImpression' || resource.resourceType === 'Observation') {
178
+ byReference.set(`${resource.resourceType}/${resource.id}`, resource);
179
+ }
180
+ }
181
+ return resources
182
+ .filter((resource) => resource.resourceType === 'Condition'
183
+ && identifierValue(resource, SYMPTOM_CASE_SLUG)
184
+ && SYMPTOM_CASE_EXT.STATUS in extMap(resource))
185
+ .map((condition) => reconstruct(condition, byReference, patientId))
186
+ .filter(Boolean);
187
+ }
188
+
189
+ function reconstruct(condition, byReference, patientId) {
190
+ const conditionId = identifierValue(condition, SYMPTOM_CASE_SLUG);
191
+ const chain = assessmentChain(condition, byReference)
192
+ .map(({ impression, previousId }) => reconstructAssessment(impression, { patientId, conditionId, previousId }, byReference));
193
+ const [latestAssessment, ...priorAssessments] = chain;
194
+ const reconstructed = {
195
+ condition: reconstructCondition(condition, patientId, conditionId),
196
+ latestAssessment,
197
+ priorAssessments,
198
+ };
199
+ try {
200
+ return new ManagedSymptom(reconstructed);
201
+ } catch (error) {
202
+ if (error instanceof ZodError) {
203
+ logger.warn({ conditionId }, 'skipping malformed symptom-case Condition');
204
+ return null;
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function assessmentChain(condition, byReference) {
211
+ const headRef = condition.stage?.[0]?.assessment?.[0]?.reference;
212
+ const impressions = [];
213
+ const seen = new Set();
214
+ let ref = headRef;
215
+ while (ref && !seen.has(ref)) {
216
+ seen.add(ref);
217
+ const impression = byReference.get(ref);
218
+ if (!impression) break;
219
+ impressions.push(impression);
220
+ ref = impression.previous?.reference;
221
+ }
222
+ return impressions.map((impression, i) => ({
223
+ impression,
224
+ previousId: impressions[i + 1] ? identifierValue(impressions[i + 1], SYMPTOM_ASSESSMENT_SLUG) : null,
225
+ }));
226
+ }
227
+
228
+ function reconstructAssessment(impression, { patientId, conditionId, previousId }, byReference) {
229
+ const ext = extMap(impression);
230
+ const findingRef = impression.finding?.[0]?.item?.reference?.reference;
231
+ const gradeObs = findingRef ? byReference.get(findingRef) : null;
232
+ const gradeExt = extMap(gradeObs || {});
233
+ return {
234
+ assessmentId: identifierValue(impression, SYMPTOM_ASSESSMENT_SLUG),
235
+ conditionId,
236
+ patientId,
237
+ performedAt: impression.effectiveDateTime,
238
+ previousId,
239
+ grade: reconstructGrade(gradeObs),
240
+ trajectory: ext[ASSESSMENT_EXT.TRAJECTORY] || null,
241
+ decisionPosture: ext[ASSESSMENT_EXT.DECISION_POSTURE] || null,
242
+ gradeVerification: gradeExt[GRADE_EXT.VERIFICATION] || 'provisional',
243
+ reasoning: ext[ASSESSMENT_EXT.REASONING] || null,
244
+ postureReason: ext[ASSESSMENT_EXT.POSTURE_REASON] || null,
245
+ problemRepresentation: impression.summary || null,
246
+ gradeThresholdClause: gradeExt[GRADE_EXT.THRESHOLD_CLAUSE] || null,
247
+ };
248
+ }
249
+
250
+ function reconstructGrade(obs) {
251
+ const grade = { bestEstimate: null, confidence: null, possibleRange: null, reasoning: '' };
252
+ if (!obs) return grade;
253
+ const ext = extMap(obs);
254
+ if (typeof obs.valueInteger === 'number') grade.bestEstimate = obs.valueInteger;
255
+ if (ext[GRADE_EXT.CONFIDENCE]) grade.confidence = ext[GRADE_EXT.CONFIDENCE];
256
+ if (ext[GRADE_EXT.RANGE]) {
257
+ const [lo, hi] = ext[GRADE_EXT.RANGE].split('-').map((n) => Number.parseInt(n, 10));
258
+ if (Number.isInteger(lo) && Number.isInteger(hi)) grade.possibleRange = [lo, hi];
259
+ }
260
+ if (ext[GRADE_EXT.REASONING]) grade.reasoning = ext[GRADE_EXT.REASONING];
261
+ return grade;
262
+ }
263
+
264
+ function reconstructCondition(condition, patientId, conditionId) {
265
+ const ext = extMap(condition);
266
+ const code = condition.code || {};
267
+ const coding = code.coding?.[0] || {};
268
+ return {
269
+ conditionId,
270
+ patientId,
271
+ ctcaeTerm: coding.code || code.text || null,
272
+ clinicalStatus: ext[SYMPTOM_CASE_EXT.STATUS],
273
+ onsetAt: condition.onsetDateTime,
274
+ abatementAt: condition.abatementDateTime || ext[SYMPTOM_CASE_EXT.LEGACY_ABATEMENT] || null,
275
+ closeReason: ext[SYMPTOM_CASE_EXT.CLOSE_REASON] || null,
276
+ temporality: ext[SYMPTOM_CASE_EXT.TEMPORALITY] || null,
277
+ evidenceEpisodeIds: extValues(condition, SYMPTOM_CASE_EXT.EVIDENCE_EPISODE_IDS),
278
+ currentEpisodeId: ext[SYMPTOM_CASE_EXT.CURRENT_EPISODE_ID] || null,
279
+ evidenceMentionIds: extValues(condition, SYMPTOM_CASE_EXT.EVIDENCE_MENTION_IDS),
280
+ lastTransitionReason: condition.note?.[0]?.text || null,
281
+ recurrenceCount: ext[SYMPTOM_CASE_EXT.RECURRENCE_COUNT] ?? 0,
282
+ reactivatedAt: ext[SYMPTOM_CASE_EXT.REACTIVATED_AT] || null,
283
+ };
284
+ }
285
+
62
286
  module.exports = {
63
- projectSymptomCase,
64
- PROJECTOR_NAME,
287
+ toFhir,
288
+ fromFhir,
65
289
  };
@@ -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 { SYMPTOM_ASSESSMENT_SLUG, CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, ROUTING_ASSESSMENT_SLUG, RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
4
- const { ASSESSMENT_EXT, ROUTING_EXT, CLUSTER_EXT, PALLIATIVE_EXT } = require('../constants/extensionSlugs');
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');
5
5
 
6
6
  const CLUSTER_STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
7
7
  const CLUSTER_LIST_SEPARATOR = '; ';
@@ -51,32 +51,6 @@ class ClinicalImpression {
51
51
  return new ClinicalImpression(payload);
52
52
  }
53
53
 
54
- static fromSymptomAssessment({ assessment, dropPreviousTo = null, hasGradeSignal = false }) {
55
- const extensions = [];
56
- if (assessment.trajectory) extensions.push(extension(ASSESSMENT_EXT.TRAJECTORY, { valueString: assessment.trajectory }));
57
- if (assessment.decisionPosture) extensions.push(extension(ASSESSMENT_EXT.DECISION_POSTURE, { valueString: assessment.decisionPosture }));
58
- if (assessment.postureReason) extensions.push(extension(ASSESSMENT_EXT.POSTURE_REASON, { valueString: assessment.postureReason }));
59
- if (assessment.reasoning) extensions.push(extension(ASSESSMENT_EXT.REASONING, { valueString: assessment.reasoning }));
60
- const payload = {
61
- resourceType: 'ClinicalImpression',
62
- id: fhirId(assessment.assessmentId),
63
- identifier: [identifier(SYMPTOM_ASSESSMENT_SLUG, assessment.assessmentId)],
64
- status: 'completed',
65
- subject: patientReference(assessment.patientId),
66
- effectiveDateTime: toIso(assessment.performedAt),
67
- problem: [reference('Condition', assessment.conditionId)],
68
- extension: extensions,
69
- };
70
- if (hasGradeSignal) {
71
- payload.finding = [{ item: { reference: reference('Observation', `${assessment.assessmentId}-grade`) } }];
72
- }
73
- if (assessment.problemRepresentation) payload.summary = assessment.problemRepresentation;
74
- if (assessment.previousId && !(dropPreviousTo && dropPreviousTo.has(assessment.previousId))) {
75
- payload.previous = reference('ClinicalImpression', assessment.previousId);
76
- }
77
- return new ClinicalImpression(payload);
78
- }
79
-
80
54
  static fromRecommendationPlan({ plan }) {
81
55
  const finding = [...plan.elements]
82
56
  .sort((a, b) => a.rank - b.rank)
@@ -1,96 +1,12 @@
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 { SYMPTOM_CASE_SLUG } = require('../constants/projectionSlugs');
5
- const { SYMPTOM_CASE_EXT } = require('../constants/extensionSlugs');
6
-
7
- const CLINICAL_STATUS_TO_FHIR = {
8
- resolved: 'resolved',
9
- inactive: 'inactive',
10
- };
11
-
12
- const RESOLUTION_CLOSE_REASONS = new Set([
13
- 'resolved', 'team_resolved', 'resolved_spontaneously', 'resolved_by_intervention',
14
- ]);
15
-
16
- const VALID_VERIFICATION = new Set(['confirmed', 'provisional', 'unconfirmed']);
17
-
18
- const PROVENANCE_PARTICIPANT_TYPE_SYSTEM = 'http://terminology.hl7.org/CodeSystem/provenance-participant-type';
19
-
20
- function fhirClinicalStatusFor(condition) {
21
- const status = condition.clinicalStatus;
22
- if (status === 'characterizing' || status === 'monitoring') {
23
- return condition.recurrenceCount > 0 ? 'recurrence' : 'active';
24
- }
25
- if (status === 'closed') {
26
- return RESOLUTION_CLOSE_REASONS.has((condition.closeReason || '').trim().toLowerCase()) ? 'resolved' : 'inactive';
27
- }
28
- return CLINICAL_STATUS_TO_FHIR[status];
29
- }
30
4
 
31
5
  class Condition {
32
6
  constructor(payload) {
33
7
  Object.assign(this, payload);
34
8
  }
35
9
 
36
- static fromSymptomCase({ condition, verification, headAssessmentId = null }) {
37
- const systems = getSystems();
38
- const clinicalStatusCode = fhirClinicalStatusFor(condition);
39
- const verificationCode = VALID_VERIFICATION.has(verification) ? verification : 'provisional';
40
- const code = condition.ctcaeTerm && condition.ctcaeTerm !== 'other'
41
- ? codeableConcept(condition.ctcaeTerm, { code: condition.ctcaeTerm, system: codeSystemUrl('pro-ctcae'), display: condition.ctcaeTerm })
42
- : codeableConcept(condition.ctcaeTerm || 'other');
43
- const extensions = [extension(SYMPTOM_CASE_EXT.STATUS, { valueString: condition.clinicalStatus })];
44
- const payload = {
45
- resourceType: 'Condition',
46
- id: fhirId(condition.conditionId),
47
- identifier: [identifier(SYMPTOM_CASE_SLUG, condition.conditionId)],
48
- clinicalStatus: codeableConcept(clinicalStatusCode, { code: clinicalStatusCode, system: systems.conditionClinical }),
49
- verificationStatus: codeableConcept(verificationCode, { code: verificationCode, system: systems.conditionVerStatus }),
50
- category: [
51
- codeableConcept('problem-list-item', { code: 'problem-list-item', system: systems.conditionCategory }),
52
- codeableConcept('PRO-CTCAE patient-reported symptom (agent-tracked case)', { code: 'proctcae-symptom', system: codeSystemUrl('condition-category') }),
53
- ],
54
- code,
55
- subject: patientReference(condition.patientId),
56
- participant: [{
57
- function: codeableConcept('Informant', { code: 'informant', system: PROVENANCE_PARTICIPANT_TYPE_SYSTEM }),
58
- actor: patientReference(condition.patientId),
59
- }],
60
- onsetDateTime: toIso(condition.onsetAt),
61
- extension: extensions,
62
- };
63
- if (condition.abatementAt) {
64
- if (clinicalStatusCode === 'resolved') {
65
- payload.abatementDateTime = toIso(condition.abatementAt);
66
- } else {
67
- extensions.push(extension(SYMPTOM_CASE_EXT.LEGACY_ABATEMENT, { valueDateTime: toIso(condition.abatementAt) }));
68
- }
69
- }
70
- if (condition.closeReason) {
71
- extensions.push(extension(SYMPTOM_CASE_EXT.CLOSE_REASON, { valueString: condition.closeReason }));
72
- if (clinicalStatusCode === 'inactive' && condition.closeReason === 'stale_no_activity') {
73
- extensions.push(extension('presumed-inactive-by-gap', { valueBoolean: true }));
74
- }
75
- }
76
- if (condition.temporality) extensions.push(extension(SYMPTOM_CASE_EXT.TEMPORALITY, { valueString: condition.temporality }));
77
- if (condition.currentEpisodeId) extensions.push(extension(SYMPTOM_CASE_EXT.CURRENT_EPISODE_ID, { valueString: condition.currentEpisodeId }));
78
- for (const id of condition.evidenceEpisodeIds) extensions.push(extension(SYMPTOM_CASE_EXT.EVIDENCE_EPISODE_IDS, { valueString: id }));
79
- for (const id of condition.evidenceMentionIds) extensions.push(extension(SYMPTOM_CASE_EXT.EVIDENCE_MENTION_IDS, { valueString: id }));
80
- extensions.push(extension(SYMPTOM_CASE_EXT.RECURRENCE_COUNT, { valueInteger: condition.recurrenceCount }));
81
- if (condition.reactivatedAt) extensions.push(extension(SYMPTOM_CASE_EXT.REACTIVATED_AT, { valueDateTime: toIso(condition.reactivatedAt) }));
82
- if (condition.lastTransitionReason) payload.note = [{ text: condition.lastTransitionReason }];
83
- const evidence = [
84
- ...condition.evidenceEpisodeIds.map((id) => ({ reference: reference('Observation', id) })),
85
- ...condition.evidenceMentionIds.map((id) => ({ reference: reference('Observation', id) })),
86
- ];
87
- if (evidence.length) payload.evidence = evidence;
88
- if (headAssessmentId) {
89
- payload.stage = [{ assessment: [reference('ClinicalImpression', headAssessmentId)] }];
90
- }
91
- return new Condition(payload);
92
- }
93
-
94
10
  static fromMentionProjection({ fact, provenance, code, extensions, slug }) {
95
11
  const assertion = fact.assertion;
96
12
  const notes = provenance.verbatimQuotes && provenance.verbatimQuotes.length
@@ -137,4 +53,4 @@ class Condition {
137
53
  }
138
54
  }
139
55
 
140
- module.exports = { Condition, RESOLUTION_CLOSE_REASONS };
56
+ module.exports = { Condition };
@@ -1,7 +1,7 @@
1
1
  const { getSystems } = require('../config/fhirConfig');
2
2
  const { fhirId } = require('../helpers/fhirHelper');
3
3
  const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
4
- const { SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
4
+ const { observationCategory } = require('../helpers/resourceHelper');
5
5
  const { GRADE_EXT } = require('../constants/extensionSlugs');
6
6
 
7
7
  const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
@@ -74,31 +74,6 @@ class Observation {
74
74
  return new Observation(payload);
75
75
  }
76
76
 
77
- static fromSymptomGrade({ assessment }) {
78
- const grade = assessment.grade;
79
- const gradeId = `${assessment.assessmentId}-grade`;
80
- const extensions = [];
81
- if (grade.confidence !== null) extensions.push(extension(GRADE_EXT.CONFIDENCE, { valueString: grade.confidence }));
82
- if (grade.possibleRange !== null) extensions.push(extension(GRADE_EXT.RANGE, { valueString: `${grade.possibleRange[0]}-${grade.possibleRange[1]}` }));
83
- if (grade.reasoning) extensions.push(extension(GRADE_EXT.REASONING, { valueString: grade.reasoning }));
84
- if (assessment.gradeVerification) extensions.push(extension(GRADE_EXT.VERIFICATION, { valueString: assessment.gradeVerification }));
85
- if (assessment.gradeThresholdClause) extensions.push(extension(GRADE_EXT.THRESHOLD_CLAUSE, { valueString: assessment.gradeThresholdClause }));
86
- const payload = {
87
- resourceType: 'Observation',
88
- id: fhirId(gradeId),
89
- identifier: [identifier(SYMPTOM_GRADE_SLUG, gradeId)],
90
- status: 'final',
91
- category: [observationCategory('survey')],
92
- code: codeableConcept('CTCAE severity grade'),
93
- subject: patientReference(assessment.patientId),
94
- performer: [patientReference(assessment.patientId)],
95
- effectiveDateTime: toIso(assessment.performedAt),
96
- };
97
- if (grade.bestEstimate !== null) payload.valueInteger = grade.bestEstimate;
98
- if (extensions.length) payload.extension = extensions;
99
- return new Observation(payload);
100
- }
101
-
102
77
  static fromResultObservation({ patientId, obsId, code, value, unit, category, effective, now, slug }) {
103
78
  const payload = {
104
79
  resourceType: 'Observation',
@@ -120,11 +95,6 @@ class Observation {
120
95
  }
121
96
  }
122
97
 
123
- function observationCategory(code) {
124
- const label = code.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
125
- return codeableConcept(label, { code, system: getSystems().observationCategory, display: label });
126
- }
127
-
128
98
  function observedFieldComponents(observedFields) {
129
99
  const components = [];
130
100
  for (const [key, raw] of Object.entries(observedFields || {})) {
@@ -1,137 +1,27 @@
1
- const { ZodError } = require('zod');
2
-
3
1
  const { fhirId } = require('../helpers/fhirHelper');
4
- const { extMap, extValues, identifierValue } = require('../helpers/fhirReadHelper');
5
- const { SYMPTOM_CASE_SLUG, SYMPTOM_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
6
- const { SYMPTOM_CASE_EXT, ASSESSMENT_EXT, GRADE_EXT } = require('../constants/extensionSlugs');
7
- const { PROJECTOR_NAME } = require('../projections/symptomCaseProjection');
2
+ const { SYMPTOM_ASSESSMENT_SLUG, SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
8
3
  const { getFhirStore } = require('../stores/fhirStore');
9
- const { ManagedSymptom } = require('../../shared/dtos/ManagedSymptom');
10
- const { logger } = require('../../utils/logger');
11
- const { project, storeResources } = require('./fhirService');
4
+ const { toFhir, fromFhir } = require('../projections/symptomCaseProjection');
5
+ const { storeResources } = require('./fhirService');
12
6
 
13
7
  async function storeSymptomCase({ patientId, managedSymptom }) {
14
8
  if (managedSymptom.condition.patientId !== patientId) {
15
9
  throw new Error(`storeSymptomCase patientId mismatch: expected ${patientId}`);
16
10
  }
17
- const bundle = project([{ name: PROJECTOR_NAME, aggregate: managedSymptom }]);
18
- const resources = bundle.entry.map((entry) => entry.resource);
19
- const { stored } = await storeResources(resources);
11
+ const { stored } = await storeResources(toFhir(managedSymptom));
20
12
  return { count: stored.length, ids: stored };
21
13
  }
22
14
 
23
15
  async function readSymptomCases({ patientId, ctcaeTerm = null, status = null }) {
24
16
  const store = getFhirStore();
25
17
  const patient = `Patient/${fhirId(patientId)}`;
26
- const conditions = (await store.find({ patient, resourceType: 'Condition', symptomTerm: ctcaeTerm }))
27
- .filter((condition) => SYMPTOM_CASE_EXT.STATUS in extMap(condition) && identifierValue(condition, SYMPTOM_CASE_SLUG));
28
- const cases = [];
29
- for (const condition of conditions) {
30
- let managed;
31
- try {
32
- managed = new ManagedSymptom(await reconstructSymptomCase(condition, patientId, store));
33
- } catch (error) {
34
- if (error instanceof ZodError) {
35
- logger.warn({ conditionId: condition.id }, 'skipping malformed symptom-case Condition');
36
- continue;
37
- }
38
- throw error;
39
- }
40
- if (status && managed.condition.clinicalStatus !== status) continue;
41
- cases.push(managed);
42
- }
43
- return cases;
44
- }
45
-
46
- async function reconstructSymptomCase(condition, patientId, store) {
47
- const conditionId = identifierValue(condition, SYMPTOM_CASE_SLUG);
48
- const chain = await readAssessmentChain(condition, { patientId, conditionId }, store);
49
- const [latestAssessment, ...priorAssessments] = chain;
50
- return {
51
- condition: reconstructCondition(condition, patientId, conditionId),
52
- latestAssessment,
53
- priorAssessments,
54
- };
55
- }
56
-
57
- function reconstructCondition(condition, patientId, conditionId) {
58
- const ext = extMap(condition);
59
- const code = condition.code || {};
60
- const coding = (code.coding && code.coding[0]) || {};
61
- return {
62
- conditionId,
63
- patientId,
64
- ctcaeTerm: coding.code || code.text || null,
65
- clinicalStatus: ext[SYMPTOM_CASE_EXT.STATUS],
66
- onsetAt: condition.onsetDateTime,
67
- abatementAt: condition.abatementDateTime || ext[SYMPTOM_CASE_EXT.LEGACY_ABATEMENT] || null,
68
- closeReason: ext[SYMPTOM_CASE_EXT.CLOSE_REASON] || null,
69
- temporality: ext[SYMPTOM_CASE_EXT.TEMPORALITY] || null,
70
- evidenceEpisodeIds: extValues(condition, SYMPTOM_CASE_EXT.EVIDENCE_EPISODE_IDS),
71
- currentEpisodeId: ext[SYMPTOM_CASE_EXT.CURRENT_EPISODE_ID] || null,
72
- evidenceMentionIds: extValues(condition, SYMPTOM_CASE_EXT.EVIDENCE_MENTION_IDS),
73
- lastTransitionReason: (condition.note && condition.note[0] && condition.note[0].text) || null,
74
- recurrenceCount: ext[SYMPTOM_CASE_EXT.RECURRENCE_COUNT] ?? 0,
75
- reactivatedAt: ext[SYMPTOM_CASE_EXT.REACTIVATED_AT] || null,
76
- };
77
- }
78
-
79
- async function readAssessmentChain(condition, ids, store) {
80
- const headRef = condition.stage && condition.stage[0] && condition.stage[0].assessment
81
- && condition.stage[0].assessment[0] && condition.stage[0].assessment[0].reference;
82
- const impressions = [];
83
- const seen = new Set();
84
- let ref = headRef;
85
- while (ref && !seen.has(ref)) {
86
- seen.add(ref);
87
- const impression = await store.getByReference(ref);
88
- if (!impression) break;
89
- impressions.push(impression);
90
- ref = impression.previous && impression.previous.reference;
91
- }
92
- const chain = [];
93
- for (let i = 0; i < impressions.length; i++) {
94
- const previousId = impressions[i + 1] ? identifierValue(impressions[i + 1], SYMPTOM_ASSESSMENT_SLUG) : null;
95
- chain.push(await reconstructAssessment(impressions[i], { ...ids, previousId }, store));
96
- }
97
- return chain;
98
- }
99
-
100
- async function reconstructAssessment(impression, { patientId, conditionId, previousId }, store) {
101
- const ext = extMap(impression);
102
- const findingRef = impression.finding && impression.finding[0] && impression.finding[0].item
103
- && impression.finding[0].item.reference && impression.finding[0].item.reference.reference;
104
- const gradeObs = findingRef ? await store.getByReference(findingRef) : null;
105
- const gradeExt = extMap(gradeObs || {});
106
- return {
107
- assessmentId: identifierValue(impression, SYMPTOM_ASSESSMENT_SLUG),
108
- conditionId,
109
- patientId,
110
- performedAt: impression.effectiveDateTime,
111
- previousId,
112
- grade: reconstructGrade(gradeObs),
113
- trajectory: ext[ASSESSMENT_EXT.TRAJECTORY] || null,
114
- decisionPosture: ext[ASSESSMENT_EXT.DECISION_POSTURE] || null,
115
- gradeVerification: gradeExt[GRADE_EXT.VERIFICATION] || 'provisional',
116
- reasoning: ext[ASSESSMENT_EXT.REASONING] || null,
117
- postureReason: ext[ASSESSMENT_EXT.POSTURE_REASON] || null,
118
- problemRepresentation: impression.summary || null,
119
- gradeThresholdClause: gradeExt[GRADE_EXT.THRESHOLD_CLAUSE] || null,
120
- };
121
- }
122
-
123
- function reconstructGrade(obs) {
124
- const grade = { bestEstimate: null, confidence: null, possibleRange: null, reasoning: '' };
125
- if (!obs) return grade;
126
- const ext = extMap(obs);
127
- if (typeof obs.valueInteger === 'number') grade.bestEstimate = obs.valueInteger;
128
- if (ext[GRADE_EXT.CONFIDENCE]) grade.confidence = ext[GRADE_EXT.CONFIDENCE];
129
- if (ext[GRADE_EXT.RANGE]) {
130
- const [lo, hi] = ext[GRADE_EXT.RANGE].split('-').map((n) => Number.parseInt(n, 10));
131
- if (Number.isInteger(lo) && Number.isInteger(hi)) grade.possibleRange = [lo, hi];
132
- }
133
- if (ext[GRADE_EXT.REASONING]) grade.reasoning = ext[GRADE_EXT.REASONING];
134
- return grade;
18
+ const [conditions, impressions, grades] = await Promise.all([
19
+ store.find({ patient, resourceType: 'Condition', symptomTerm: ctcaeTerm }),
20
+ store.find({ patient, resourceType: 'ClinicalImpression', identifierSlug: SYMPTOM_ASSESSMENT_SLUG }),
21
+ store.find({ patient, resourceType: 'Observation', identifierSlug: SYMPTOM_GRADE_SLUG }),
22
+ ]);
23
+ return fromFhir([...conditions, ...impressions, ...grades], { patientId })
24
+ .filter((managed) => !status || managed.condition.clinicalStatus === status);
135
25
  }
136
26
 
137
27
  module.exports = {
@@ -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 } = {}) {
85
+ async find({ patient = null, resourceType = null, symptomTerm = null, identifierSlug = 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 (identifierSlug) query['resource.identifier.system'] = identifierSystem(identifierSlug);
89
90
  if (resourceType) {
90
91
  const model = await this._modelFor(resourceType);
91
92
  const envelopes = await model.find(query, null, { sort: NEWEST_FIRST }).lean();
@@ -8,6 +8,9 @@ const OPEN_STATUSES = ['characterizing', 'monitoring'];
8
8
  const CLOSED_STATUSES = ['resolved', 'inactive', 'closed'];
9
9
  const CLINICAL_STATUSES = [...OPEN_STATUSES, ...CLOSED_STATUSES];
10
10
  const GRADE_CONFIDENCE_LEVELS = ['low', 'moderate', 'high'];
11
+ const RESOLUTION_CLOSE_REASONS = new Set([
12
+ 'resolved', 'team_resolved', 'resolved_spontaneously', 'resolved_by_intervention',
13
+ ]);
11
14
 
12
15
  const gradeEstimateSchema = z.strictObject({
13
16
  bestEstimate: z.number().int().min(1).max(5).nullable().default(null),
@@ -82,4 +85,5 @@ module.exports = {
82
85
  CLINICAL_STATUSES,
83
86
  OPEN_STATUSES,
84
87
  CLOSED_STATUSES,
88
+ RESOLUTION_CLOSE_REASONS,
85
89
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.13.0-dev.1145",
3
+ "version": "5.13.0-dev.1148",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",