diff --git a/src/extractors/CSVPatientExtractor.js b/src/extractors/CSVPatientExtractor.js index ddaaeab0..14b71992 100644 --- a/src/extractors/CSVPatientExtractor.js +++ b/src/extractors/CSVPatientExtractor.js @@ -2,7 +2,8 @@ const { generateMcodeResources } = require('../templates'); const { BaseCSVExtractor } = require('./BaseCSVExtractor'); const { getEthnicityDisplay, getRaceCodesystem, - getRaceDisplay } = require('../helpers/patientUtils'); + getRaceDisplay, + maskPatientData } = require('../helpers/patientUtils'); const logger = require('../helpers/logger'); const { CSVPatientSchema } = require('../helpers/schemas/csv'); @@ -39,8 +40,9 @@ function joinAndReformatData(patientData) { } class CSVPatientExtractor extends BaseCSVExtractor { - constructor({ filePath }) { + constructor({ filePath, mask = [] }) { super({ filePath, csvSchema: CSVPatientSchema }); + this.mask = mask; } async getPatientData(mrn) { @@ -58,7 +60,11 @@ class CSVPatientExtractor extends BaseCSVExtractor { const packagedPatientData = joinAndReformatData(patientData); // 3. Generate FHIR Resources - return generateMcodeResources('Patient', packagedPatientData); + const bundle = generateMcodeResources('Patient', packagedPatientData); + + // mask fields in the patient data if specified in mask array + if (this.mask.length > 0) maskPatientData(bundle, this.mask); + return bundle; } } diff --git a/src/extractors/FHIRPatientExtractor.js b/src/extractors/FHIRPatientExtractor.js index 1ccf75a7..0ac7f262 100644 --- a/src/extractors/FHIRPatientExtractor.js +++ b/src/extractors/FHIRPatientExtractor.js @@ -1,9 +1,11 @@ const { BaseFHIRExtractor } = require('./BaseFHIRExtractor'); +const { maskPatientData } = require('../helpers/patientUtils.js'); class FHIRPatientExtractor extends BaseFHIRExtractor { - constructor({ baseFhirUrl, requestHeaders, version }) { + constructor({ baseFhirUrl, requestHeaders, version, mask = [] }) { super({ baseFhirUrl, requestHeaders, version }); this.resourceType = 'Patient'; + this.mask = mask; } // Override default behavior for PatientExtractor; just use MRN directly @@ -13,6 +15,12 @@ class FHIRPatientExtractor extends BaseFHIRExtractor { identifier: `MRN|${mrn}`, }; } + + async get(argumentObject) { + const bundle = await super.get(argumentObject); + if (this.mask.length > 0) maskPatientData(bundle, this.mask); + return bundle; + } } module.exports = { diff --git a/src/helpers/patientUtils.js b/src/helpers/patientUtils.js index aefc1592..b9a1212a 100644 --- a/src/helpers/patientUtils.js +++ b/src/helpers/patientUtils.js @@ -1,3 +1,7 @@ +/* eslint-disable no-underscore-dangle */ +const fhirpath = require('fhirpath'); +const { extensionArr, dataAbsentReasonExtension } = require('../templates/snippets/extension.js'); + // Based on the OMB Ethnicity table found here:http://hl7.org/fhir/us/core/STU3.1/ValueSet-omb-ethnicity-category.html const ethnicityCodeToDisplay = { '2135-2': 'Hispanic or Latino', @@ -65,7 +69,82 @@ function getRaceDisplay(code) { * @return {string} concatenated string of name values */ function getPatientName(name) { - return `${name[0].given.join(' ')} ${name[0].family}`; + return ('extension' in name[0]) ? 'masked' : `${name[0].given.join(' ')} ${name[0].family}`; +} + +/** + * Mask fields in a Patient resource with + * dataAbsentReason extension with value 'masked' + * @param {Object} bundle a FHIR bundle with a Patient resource + * @param {Array} mask an array of fields to mask. Values can be: + * ['gender','mrn','name','address','birthDate','language','ethnicity','birthsex','race'] + */ +function maskPatientData(bundle, mask) { + // get Patient resource from bundle + const patient = fhirpath.evaluate( + bundle, + 'Bundle.entry.where(resource.resourceType=\'Patient\').resource,first()', + )[0]; + + const validFields = ['gender', 'mrn', 'name', 'address', 'birthDate', 'language', 'ethnicity', 'birthsex', 'race']; + const masked = extensionArr(dataAbsentReasonExtension('masked')); + + mask.forEach((field) => { + if (!validFields.includes(field)) { + throw Error(`'${field}' is not a field that can be masked. Patient will only be extracted if all mask fields are valid. Valid fields include: Valid fields include: ${validFields.join(', ')}`); + } + // must check if the field exists in the patient resource, so we don't add unnecessary dataAbsent extensions + if (field === 'gender' && 'gender' in patient) { + delete patient.gender; + // an underscore is added when a primitive type is being replaced by an object (extension) + patient._gender = masked; + } else if (field === 'mrn' && 'identifier' in patient) { + patient.identifier = [masked]; + } else if (field === 'name' && 'name' in patient) { + patient.name = [masked]; + } else if (field === 'address' && 'address' in patient) { + patient.address = [masked]; + } else if (field === 'birthDate' && 'birthDate' in patient) { + delete patient.birthDate; + patient._birthDate = masked; + } else if (field === 'language') { + if ('communication' in patient && 'language' in patient.communication[0]) { + patient.communication[0].language = masked; + } + } else if (field === 'birthsex') { + // fields that are extensions need to be differentiated by URL using fhirpath + const birthsex = fhirpath.evaluate( + patient, + 'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex\')', + ); + // fhirpath.evaluate will return [] if there is no extension with the given URL + // so checking if the result is [] checks if the field exists to be masked + if (birthsex !== []) { + delete birthsex[0].valueCode; + birthsex[0]._valueCode = masked; + } + } else if (field === 'race') { + const race = fhirpath.evaluate( + patient, + 'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race\')', + ); + if (race !== []) { + race[0].extension[0].valueCoding = masked; + delete race[0].extension[1].valueString; + race[0].extension[1]._valueString = masked; + } + } else if (field === 'ethnicity') { + const ethnicity = fhirpath.evaluate( + patient, + 'Patient.extension.where(url=\'http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity\')', + ); + if (ethnicity !== []) { + ethnicity[0].extension[0].valueCoding = masked; + delete ethnicity[0].extension[1].valueString; + ethnicity[0].extension[1]._valueString = masked; + } + } + }); } module.exports = { @@ -73,4 +152,5 @@ module.exports = { getRaceCodesystem, getRaceDisplay, getPatientName, + maskPatientData, }; diff --git a/test/helpers/fixtures/masked-patient-bundle.json b/test/helpers/fixtures/masked-patient-bundle.json new file mode 100644 index 00000000..3b7154ce --- /dev/null +++ b/test/helpers/fixtures/masked-patient-bundle.json @@ -0,0 +1,138 @@ +{ + "resourceType": "Bundle", + "type": "collection", + "entry": [ + { + "fullUrl": "urn:uuid:119147111821125", + "resource": { + "resourceType": "Patient", + "id": "119147111821125", + "identifier": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + ], + "name": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + ], + "address": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + ], + "communication": [ + { + "language": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + } + ], + "extension": [ + { + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + }, + { + "url": "text", + "_valueString": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + } + ], + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race" + }, + { + "extension": [ + { + "url": "ombCategory", + "valueCoding": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + }, + { + "url": "text", + "_valueString": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + } + ], + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity" + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "_valueCode": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + } + ], + "_gender": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + }, + "_birthDate": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason", + "valueCode": "masked" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/test/helpers/patientUtils.test.js b/test/helpers/patientUtils.test.js index 369193ba..467ee9b6 100644 --- a/test/helpers/patientUtils.test.js +++ b/test/helpers/patientUtils.test.js @@ -1,5 +1,9 @@ -const { getEthnicityDisplay, getRaceCodesystem, getRaceDisplay, getPatientName } = require('../../src/helpers/patientUtils'); - +const _ = require('lodash'); +const { + getEthnicityDisplay, getRaceCodesystem, getRaceDisplay, getPatientName, maskPatientData, +} = require('../../src/helpers/patientUtils'); +const examplePatient = require('../extractors/fixtures/csv-patient-bundle.json'); +const exampleMaskedPatient = require('./fixtures/masked-patient-bundle.json'); describe('PatientUtils', () => { describe('getEthnicityDisplay', () => { @@ -79,4 +83,22 @@ describe('PatientUtils', () => { expect(getPatientName(name)).toBe(expectedConcatenatedName); }); }); + describe('maskPatientData', () => { + test('bundle should remain the same if no fields are specified to be masked', () => { + const bundle = _.cloneDeep(examplePatient); + maskPatientData(bundle, []); + expect(bundle).toEqual(examplePatient); + }); + + test('bundle should be modified to have dataAbsentReason for all fields specified in mask', () => { + const bundle = _.cloneDeep(examplePatient); + maskPatientData(bundle, ['gender', 'mrn', 'name', 'address', 'birthDate', 'language', 'ethnicity', 'birthsex', 'race']); + expect(bundle).toEqual(exampleMaskedPatient); + }); + + test('should throw error when provided an invalid field to mask', () => { + const bundle = _.cloneDeep(examplePatient); + expect(() => maskPatientData(bundle, ['this is an invalid field', 'mrn'])).toThrowError(); + }); + }); });