Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/extractors/CSVPatientExtractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}

Expand Down
10 changes: 9 additions & 1 deletion src/extractors/FHIRPatientExtractor.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand Down
82 changes: 81 additions & 1 deletion src/helpers/patientUtils.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -65,12 +69,88 @@ 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}`;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking for opinions on what should actually be returned here when the name field is masked (this is used in EpicCancerDiseaseStatus and EpicTreatmentPlanChange Extractors for context)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the string 'masked' is good! I don't think it's safe to return null or undefined since it might get applied to a required field, and I think it might be too much for right now to support returning a full dataAbsent extesion.

}

/**
* 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 = {
getEthnicityDisplay,
getRaceCodesystem,
getRaceDisplay,
getPatientName,
maskPatientData,
};
138 changes: 138 additions & 0 deletions test/helpers/fixtures/masked-patient-bundle.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
]
}
26 changes: 24 additions & 2 deletions test/helpers/patientUtils.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});