Skip to content

Commit 9cdcab4

Browse files
committed
adding ability to mask fields in Patient
1 parent 1bbbbe4 commit 9cdcab4

File tree

5 files changed

+260
-6
lines changed

5 files changed

+260
-6
lines changed

src/extractors/CSVPatientExtractor.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ const { generateMcodeResources } = require('../templates');
22
const { BaseCSVExtractor } = require('./BaseCSVExtractor');
33
const { getEthnicityDisplay,
44
getRaceCodesystem,
5-
getRaceDisplay } = require('../helpers/patientUtils');
5+
getRaceDisplay,
6+
maskPatientData } = require('../helpers/patientUtils');
67
const logger = require('../helpers/logger');
78
const { CSVPatientSchema } = require('../helpers/schemas/csv');
89

@@ -39,8 +40,9 @@ function joinAndReformatData(patientData) {
3940
}
4041

4142
class CSVPatientExtractor extends BaseCSVExtractor {
42-
constructor({ filePath }) {
43+
constructor({ filePath, mask = [] }) {
4344
super({ filePath, csvSchema: CSVPatientSchema });
45+
this.mask = mask;
4446
}
4547

4648
async getPatientData(mrn) {
@@ -58,7 +60,11 @@ class CSVPatientExtractor extends BaseCSVExtractor {
5860
const packagedPatientData = joinAndReformatData(patientData);
5961

6062
// 3. Generate FHIR Resources
61-
return generateMcodeResources('Patient', packagedPatientData);
63+
const bundle = generateMcodeResources('Patient', packagedPatientData);
64+
65+
// mask fields in the patient data if specified in mask array
66+
maskPatientData(bundle, this.mask);
67+
return bundle;
6268
}
6369
}
6470

src/extractors/FHIRPatientExtractor.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const { BaseFHIRExtractor } = require('./BaseFHIRExtractor');
2+
const { maskPatientData } = require('../helpers/patientUtils.js');
23

34
class FHIRPatientExtractor extends BaseFHIRExtractor {
4-
constructor({ baseFhirUrl, requestHeaders, version }) {
5+
constructor({ baseFhirUrl, requestHeaders, version, mask = [] }) {
56
super({ baseFhirUrl, requestHeaders, version });
67
this.resourceType = 'Patient';
8+
this.mask = mask;
79
}
810

911
// Override default behavior for PatientExtractor; just use MRN directly
@@ -13,6 +15,12 @@ class FHIRPatientExtractor extends BaseFHIRExtractor {
1315
identifier: `MRN|${mrn}`,
1416
};
1517
}
18+
19+
async get(argumentObject) {
20+
const bundle = await super.get(argumentObject);
21+
maskPatientData(bundle, this.mask);
22+
return bundle;
23+
}
1624
}
1725

1826
module.exports = {

src/helpers/patientUtils.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/* eslint-disable no-underscore-dangle */
2+
const fhirpath = require('fhirpath');
3+
const { extensionArr, dataAbsentReasonExtension } = require('../templates/snippets/extension.js');
4+
15
// Based on the OMB Ethnicity table found here:http://hl7.org/fhir/us/core/STU3.1/ValueSet-omb-ethnicity-category.html
26
const ethnicityCodeToDisplay = {
37
'2135-2': 'Hispanic or Latino',
@@ -68,9 +72,85 @@ function getPatientName(name) {
6872
return `${name[0].given.join(' ')} ${name[0].family}`;
6973
}
7074

75+
/**
76+
* Mask fields in a Patient resource with
77+
* dataAbsentReason extension with value 'masked'
78+
* @param {Object} bundle a FHIR bundle with a Patient resource
79+
* @param {Array} mask an array of fields to mask. Values can be:
80+
* ['gender','mrn','name','address','birthDate','language','ethnicity','birthsex','race']
81+
*/
82+
function maskPatientData(bundle, mask) {
83+
// get Patient resource from bundle
84+
const patient = fhirpath.evaluate(
85+
bundle,
86+
'Bundle.entry.where(resource.resourceType=\'Patient\').resource,first()',
87+
)[0];
88+
89+
const validFields = ['gender', 'mrn', 'name', 'address', 'birthDate', 'language', 'ethnicity', 'birthsex', 'race'];
90+
const masked = extensionArr(dataAbsentReasonExtension('masked'));
91+
92+
mask.forEach((field) => {
93+
if (!validFields.includes(field)) {
94+
throw Error(`'${field}' is not a field that can be masked. Valid fields include: 'gender','mrn','name','address','birthDate','language','ethnicity','birthsex','race'`);
95+
}
96+
// must check if the field exists in the patient resource, so we don't add unnecessary dataAbsent extensions
97+
if (field === 'gender' && 'gender' in patient) {
98+
delete patient.gender;
99+
// an underscore is added when a primitive type is being replaced by an object (extension)
100+
patient._gender = masked;
101+
} else if (field === 'mrn' && 'identifier' in patient) {
102+
patient.identifier = [masked];
103+
} else if (field === 'name' && 'name' in patient) {
104+
patient.name = [masked];
105+
} else if (field === 'address' && 'address' in patient) {
106+
patient.address = [masked];
107+
} else if (field === 'birthDate' && 'birthDate' in patient) {
108+
delete patient.birthDate;
109+
patient._birthDate = masked;
110+
} else if (field === 'language') {
111+
if ('communication' in patient && 'language' in patient.communication[0]) {
112+
patient.communication[0].language = masked;
113+
}
114+
} else if (field === 'birthsex') {
115+
// fields that are extensions need to be differentiated by URL using fhirpath
116+
const birthsex = fhirpath.evaluate(
117+
patient,
118+
'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex\')',
119+
);
120+
// fhirpath.evaluate will return [] if there is no extension with the given URL
121+
// so checking if the result is [] checks if the field exists to be masked
122+
if (birthsex !== []) {
123+
delete birthsex[0].valueCode;
124+
birthsex[0]._valueCode = masked;
125+
}
126+
} else if (field === 'race') {
127+
const race = fhirpath.evaluate(
128+
patient,
129+
'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race\')',
130+
);
131+
if (race !== []) {
132+
race[0].extension[0].valueCoding = masked;
133+
delete race[0].extension[1].valueString;
134+
race[0].extension[1]._valueString = masked;
135+
}
136+
} else if (field === 'ethnicity') {
137+
const ethnicity = fhirpath.evaluate(
138+
patient,
139+
'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity\')',
140+
);
141+
if (ethnicity !== []) {
142+
ethnicity[0].extension[0].valueCoding = masked;
143+
delete ethnicity[0].extension[1].valueString;
144+
ethnicity[0].extension[1]._valueString = masked;
145+
}
146+
}
147+
});
148+
}
149+
71150
module.exports = {
72151
getEthnicityDisplay,
73152
getRaceCodesystem,
74153
getRaceDisplay,
75154
getPatientName,
155+
maskPatientData,
76156
};
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
{
2+
"resourceType": "Bundle",
3+
"type": "collection",
4+
"entry": [
5+
{
6+
"fullUrl": "urn:uuid:119147111821125",
7+
"resource": {
8+
"resourceType": "Patient",
9+
"id": "119147111821125",
10+
"identifier": [
11+
{
12+
"extension": [
13+
{
14+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
15+
"valueCode": "masked"
16+
}
17+
]
18+
}
19+
],
20+
"name": [
21+
{
22+
"extension": [
23+
{
24+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
25+
"valueCode": "masked"
26+
}
27+
]
28+
}
29+
],
30+
"address": [
31+
{
32+
"extension": [
33+
{
34+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
35+
"valueCode": "masked"
36+
}
37+
]
38+
}
39+
],
40+
"communication": [
41+
{
42+
"language": {
43+
"extension": [
44+
{
45+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
46+
"valueCode": "masked"
47+
}
48+
]
49+
}
50+
}
51+
],
52+
"extension": [
53+
{
54+
"extension": [
55+
{
56+
"url": "ombCategory",
57+
"valueCoding": {
58+
"extension": [
59+
{
60+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
61+
"valueCode": "masked"
62+
}
63+
]
64+
}
65+
},
66+
{
67+
"url": "text",
68+
"_valueString": {
69+
"extension": [
70+
{
71+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
72+
"valueCode": "masked"
73+
}
74+
]
75+
}
76+
}
77+
],
78+
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race"
79+
},
80+
{
81+
"extension": [
82+
{
83+
"url": "ombCategory",
84+
"valueCoding": {
85+
"extension": [
86+
{
87+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
88+
"valueCode": "masked"
89+
}
90+
]
91+
}
92+
},
93+
{
94+
"url": "text",
95+
"_valueString": {
96+
"extension": [
97+
{
98+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
99+
"valueCode": "masked"
100+
}
101+
]
102+
}
103+
}
104+
],
105+
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity"
106+
},
107+
{
108+
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex",
109+
"_valueCode": {
110+
"extension": [
111+
{
112+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
113+
"valueCode": "masked"
114+
}
115+
]
116+
}
117+
}
118+
],
119+
"_gender": {
120+
"extension": [
121+
{
122+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
123+
"valueCode": "masked"
124+
}
125+
]
126+
},
127+
"_birthDate": {
128+
"extension": [
129+
{
130+
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
131+
"valueCode": "masked"
132+
}
133+
]
134+
}
135+
}
136+
}
137+
]
138+
}

test/helpers/patientUtils.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
const { getEthnicityDisplay, getRaceCodesystem, getRaceDisplay, getPatientName } = require('../../src/helpers/patientUtils');
2-
1+
const _ = require('lodash');
2+
const {
3+
getEthnicityDisplay, getRaceCodesystem, getRaceDisplay, getPatientName, maskPatientData,
4+
} = require('../../src/helpers/patientUtils');
5+
const examplePatient = require('../extractors/fixtures/csv-patient-bundle.json');
6+
const exampleMaskedPatient = require('./fixtures/masked-patient-bundle.json');
37

48
describe('PatientUtils', () => {
59
describe('getEthnicityDisplay', () => {
@@ -79,4 +83,22 @@ describe('PatientUtils', () => {
7983
expect(getPatientName(name)).toBe(expectedConcatenatedName);
8084
});
8185
});
86+
describe('maskPatientData', () => {
87+
test('bundle should remain the same if no fields are specified to be masked', () => {
88+
const bundle = _.cloneDeep(examplePatient);
89+
maskPatientData(bundle, []);
90+
expect(bundle).toEqual(examplePatient);
91+
});
92+
93+
test('bundle should be modified to have dataAbsentReason for all fields specified in mask', () => {
94+
const bundle = _.cloneDeep(examplePatient);
95+
maskPatientData(bundle, ['gender', 'mrn', 'name', 'address', 'birthDate', 'language', 'ethnicity', 'birthsex', 'race']);
96+
expect(bundle).toEqual(exampleMaskedPatient);
97+
});
98+
99+
test('should throw error when provided an invalid field to mask', () => {
100+
const bundle = _.cloneDeep(examplePatient);
101+
expect(() => maskPatientData(bundle, ['this is an invalid field', 'mrn'])).toThrowError();
102+
});
103+
});
82104
});

0 commit comments

Comments
 (0)