diff --git a/prime-router/src/main/kotlin/cli/PIIRemovalCommands.kt b/prime-router/src/main/kotlin/cli/PIIRemovalCommands.kt new file mode 100644 index 00000000000..c74cb829cc1 --- /dev/null +++ b/prime-router/src/main/kotlin/cli/PIIRemovalCommands.kt @@ -0,0 +1,211 @@ +package gov.cdc.prime.router.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.CliktError +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.file +import fhirengine.engine.CustomFhirPathFunctions +import gov.cdc.prime.router.common.JacksonMapperUtilities +import gov.cdc.prime.router.fhirengine.translation.hl7.FhirTransformer +import gov.cdc.prime.router.fhirengine.translation.hl7.utils.FhirPathUtils +import gov.cdc.prime.router.fhirengine.utils.FhirTranscoder +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.ContactPoint +import org.hl7.fhir.r4.model.Organization +import org.hl7.fhir.r4.model.Organization.OrganizationContactComponent +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Patient.ContactComponent +import org.hl7.fhir.r4.model.Practitioner +import org.hl7.fhir.r4.model.StringType + +class PIIRemovalCommands : CliktCommand( + name = "piiRemoval", + help = "Remove PII" +) { + /** + * The input file to process. + */ + private val inputFile by option("-i", "--input-file", help = "Input file to process") + .file(true, canBeDir = false, mustBeReadable = true).required() + + /** + * Output file to write the data with PII removed. + */ + private val outputFile by option("-o", "--output-file", help = "output file") + .file() + + /** + * FHIR paths for ids to remove + */ + val idPaths = arrayListOf( + "Bundle.entry.resource.ofType(Patient).identifier.value", + "Bundle.entry.resource.ofType(ServiceRequest).requester.resolve().practitioner.resolve().identifier.value", + "Bundle.entry.resource.ofType(DiagnosticReport).identifier.value" + ) + + /** + * Method called when the command is run + */ + override fun run() { + // Read the contents of the file + val contents = inputFile.inputStream().readBytes().toString(Charsets.UTF_8) + if (contents.isBlank()) throw CliktError("File ${inputFile.absolutePath} is empty.") + + // Check on the extension of the file for supported operations + if (inputFile.extension.uppercase() != "FHIR") { + throw CliktError("File ${inputFile.absolutePath} is not a FHIR file.") + } + var bundle = FhirTranscoder.decode(contents) + + bundle.entry.map { it.resource }.filterIsInstance() + .forEach { patient -> + patient.name.forEach { name -> + name.given = mutableListOf(StringType(getFakeValueForElementCall("PERSON_GIVEN_NAME"))) + } + patient.address.forEach { address -> + address.line = mutableListOf(StringType(getFakeValueForElementCall("STREET"))) + } + patient.telecom.forEach { telecom -> + handleTelecom(telecom) + } + patient.contact.forEach { contact -> + handlePatientContact(contact) + } + } + + bundle.entry.map { it.resource }.filterIsInstance() + .forEach { organization -> + organization.address.forEach { address -> + address.line = mutableListOf(StringType(getFakeValueForElementCall("STREET"))) + } + organization.telecom.forEach { telecom -> + handleTelecom(telecom) + } + organization.contact.forEach { contact -> + handleOrganizationalContact(contact) + } + } + + bundle.entry.map { it.resource }.filterIsInstance() + .forEach { practitioner -> + practitioner.address.forEach { address -> + address.line = mutableListOf(StringType(getFakeValueForElementCall("STREET"))) + address.city = getFakeValueForElementCallUsingGeoData("CITY", address.state) + address.postalCode = getFakeValueForElementCallUsingGeoData("POSTAL_CODE", address.state) + address.district = getFakeValueForElementCallUsingGeoData("COUNTY", address.state) + } + practitioner.telecom.forEach { telecom -> + handleTelecom(telecom) + } + practitioner.name.forEach { name -> + name.given = mutableListOf(StringType(getFakeValueForElementCall("PERSON_GIVEN_NAME"))) + } + } + + bundle = FhirTransformer("classpath:/metadata/fhir_transforms/common/remove-pii-enrichment.yml").process(bundle) + + val jsonObject = JacksonMapperUtilities.defaultMapper + .readValue(FhirTranscoder.encode(bundle), Any::class.java) + var prettyText = JacksonMapperUtilities.defaultMapper.writeValueAsString(jsonObject) + prettyText = replaceIds(bundle, prettyText) + + // Write the output to the screen or a file. + if (outputFile != null) { + outputFile!!.writeText(prettyText, Charsets.UTF_8) + } + echo("Wrote output to ${outputFile!!.absolutePath}") + } + + /** + * Replaces the patient contact PII data + */ + private fun handlePatientContact(contact: ContactComponent): ContactComponent { + contact.name.given = mutableListOf(StringType(getFakeValueForElementCall("PERSON_GIVEN_NAME"))) + contact.name.family = getFakeValueForElementCall("PERSON_FAMILY_NAME") + contact.address.line = mutableListOf(StringType(getFakeValueForElementCall("STREET"))) + contact.address.city = getFakeValueForElementCallUsingGeoData("CITY", contact.address.state) + contact.address.postalCode = getFakeValueForElementCallUsingGeoData("POSTAL_CODE", contact.address.state) + contact.address.district = getFakeValueForElementCallUsingGeoData("COUNTY", contact.address.state) + contact.telecom.forEach { telecom -> + handleTelecom(telecom) + } + return contact + } + + /** + * Replaces the organizational contact PII. Unfortunately needs to be repeated because they do not share a common + * ancestor + */ + private fun handleOrganizationalContact(contact: OrganizationContactComponent): OrganizationContactComponent { + contact.name.given = mutableListOf(StringType(getFakeValueForElementCall("PERSON_GIVEN_NAME"))) + contact.name.family = getFakeValueForElementCall("PERSON_FAMILY_NAME") + contact.address.line = mutableListOf(StringType(getFakeValueForElementCall("STREET"))) + contact.address.city = getFakeValueForElementCallUsingGeoData("CITY", contact.address.state) + contact.address.postalCode = getFakeValueForElementCallUsingGeoData("POSTAL_CODE", contact.address.state) + contact.address.district = getFakeValueForElementCallUsingGeoData("COUNTY", contact.address.state) + contact.telecom.forEach { telecom -> + handleTelecom(telecom) + } + return contact + } + + /** + * Replaces PII in a telecom + */ + private fun handleTelecom(telecom: ContactPoint): ContactPoint { + if (telecom.system == ContactPoint.ContactPointSystem.EMAIL) { + telecom.value = getFakeValueForElementCall("EMAIL") + } else if (telecom.system == ContactPoint.ContactPointSystem.PHONE || + telecom.system == ContactPoint.ContactPointSystem.FAX + ) { + telecom.value = getFakeValueForElementCall("TELEPHONE") + } + return telecom + } + + /** + * Replaces the required IDs in the bundle + */ + private fun replaceIds(bundle: Bundle, prettyText: String): String { + var updatedBundle = prettyText + idPaths.forEach { path -> + updatedBundle = replaceId(bundle, path, updatedBundle) + } + return updatedBundle + } + + /** + * Replaces the ID for a specific ID + */ + private fun replaceId(bundle: Bundle, path: String, prettyText: String): String { + FhirPathUtils.evaluate( + null, + bundle, + bundle, + path + ).forEach { resourceId -> + val newIdentifier = getFakeValueForElementCall("UUID") + return prettyText.replace(resourceId.primitiveValue(), newIdentifier, true) + } + return prettyText + } + + /** + * Gets a fake value for a given type + */ + private fun getFakeValueForElementCall(dataType: String): String { + return CustomFhirPathFunctions().getFakeValueForElement( + mutableListOf(mutableListOf(StringType(dataType))) + )[0].primitiveValue() + } + + /** + * Gets a fake value for a given type that requires geo data + */ + private fun getFakeValueForElementCallUsingGeoData(dataType: String, state: String): String { + return CustomFhirPathFunctions().getFakeValueForElement( + mutableListOf(mutableListOf(StringType(dataType)), mutableListOf(StringType(state))) + )[0].primitiveValue() + } +} \ No newline at end of file diff --git a/prime-router/src/main/kotlin/cli/main.kt b/prime-router/src/main/kotlin/cli/main.kt index 32ec25668d5..d930724a661 100644 --- a/prime-router/src/main/kotlin/cli/main.kt +++ b/prime-router/src/main/kotlin/cli/main.kt @@ -299,6 +299,7 @@ fun main(args: Array) = RouterCli() ProcessHl7Commands(), ValidateTranslationSchemaCommand(), SyncTranslationSchemaCommand(), - ValidateYAMLCommand() + ValidateYAMLCommand(), + PIIRemovalCommands() ).context { terminal = Terminal(ansiLevel = AnsiLevel.TRUECOLOR) } .main(args) \ No newline at end of file diff --git a/prime-router/src/main/kotlin/cli/tests/RemovePIITest.kt b/prime-router/src/main/kotlin/cli/tests/RemovePIITest.kt new file mode 100644 index 00000000000..f8ba88c2d41 --- /dev/null +++ b/prime-router/src/main/kotlin/cli/tests/RemovePIITest.kt @@ -0,0 +1,402 @@ +package gov.cdc.prime.router.cli.tests + +import com.github.ajalt.clikt.testing.test +import gov.cdc.prime.router.cli.PIIRemovalCommands +import gov.cdc.prime.router.common.Environment +import gov.cdc.prime.router.fhirengine.translation.hl7.utils.FhirPathUtils +import gov.cdc.prime.router.fhirengine.utils.FhirTranscoder +import org.hl7.fhir.r4.model.Address +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Organization +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Practitioner +import org.hl7.fhir.r4.model.ServiceRequest +import org.hl7.fhir.r4.model.Specimen +import java.io.File +import java.nio.file.Paths + +class RemovePIITest : CoolTest() { + /** + * The name of the call + */ + override val name: String + get() = "removepiicheck" + + /** + * Description of the call + */ + override val description: String + get() = "Tests that all pii is removed from a message" + + /** + * Type of test + */ + override val status: TestStatus + get() = TestStatus.SMOKE + + /** + * Function that is run when this command is called + */ + override suspend fun run(environment: Environment, options: CoolTestOptions): Boolean { + ugly("Starting remove PII test") + val inputFilePath = Paths.get("").toAbsolutePath().toString() + "/src/main/kotlin/cli/tests/fakePII.fhir" + val outputFilePath = Paths.get("").toAbsolutePath().toString() + + "/src/main/kotlin/cli/tests/piiRemoved.fhir" + + PIIRemovalCommands().test( + "-i $inputFilePath -o $outputFilePath" + ) + + val inputContent = File(inputFilePath).inputStream().readBytes().toString(Charsets.UTF_8) + val inputBundle = FhirTranscoder.decode(inputContent) + val outputContent = File(outputFilePath).inputStream().readBytes().toString(Charsets.UTF_8) + val outputBundle = FhirTranscoder.decode(outputContent) + + if (!testIdsRemoved(inputBundle, outputContent)) { + ugly("Not all IDs removed. Test failed.") + return false + } + + if (!testPatientPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all patient PII removed. Test failed.") + return false + } + + if (!testOrganizationPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all organization PII removed. Test failed.") + return false + } + + if (!testPractitionerPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all practitioner PII removed. Test failed.") + return false + } + + if (!testServiceRequestPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all service request PII removed. Test failed.") + return false + } + + if (!testObservationPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all observation PII removed. Test failed.") + return false + } + + if (!testSpecimenPIIRemoved(inputBundle, outputBundle)) { + ugly("Not all specimen PII removed. Test failed.") + return false + } + + ugly("PII removal test passed") + return true + } + + /** + * Tests patient PII is replaced + */ + private fun testPatientPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var patientIndex = 0 + val outputPatients = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputPatient -> + val outputPatient = outputPatients[patientIndex] + var nameIndex = 0 + if (inputPatient.birthDate == outputPatient.birthDate) { + return false + } + inputPatient.name.forEach { name -> + if (!testName(name, outputPatient.name[nameIndex])) { + return false + } + nameIndex++ + } + + var addressIndex = 0 + inputPatient.address.forEach { address -> + testAddress(address, outputPatient.address[addressIndex]) + addressIndex++ + } + + var telecomIndex = 0 + inputPatient.telecom.forEach { telecom -> + if (!telecom.value.isNullOrBlank() && telecom.value == outputPatient.telecom[telecomIndex].value) { + return false + } + telecomIndex++ + } + + var contactIndex = 0 + inputPatient.contact.forEach { contact -> + val outputContact = outputPatient.contact[contactIndex] + if (!testName(contact.name, outputContact.name)) { + return false + } + if (!testAddress(contact.address, outputContact.address)) { + return false + } + + telecomIndex = 0 + contact.telecom.forEach { telecom -> + if (telecom.value == outputPatient.telecom[telecomIndex].value) { + return false + } + telecomIndex++ + } + + contactIndex++ + } + patientIndex++ + } + return true + } + + /** + * Tests name PII is replaced + */ + private fun testName(inputName: HumanName, outputName: HumanName): Boolean { + var givenNameIndex = 0 + inputName.given.forEach { givenName -> + if (!givenName.isEmpty && inputName.given == outputName.given[givenNameIndex]) { + return false + } + givenNameIndex++ + } + if (!inputName.family.isNullOrBlank() && + inputName.family == outputName.family + ) { + return false + } + return true + } + + /** + * Tests address PII is replaced + */ + private fun testAddress(inputAddress: Address, outputAddress: Address): Boolean { + var addressLineIndex = 0 + inputAddress.line.forEach { addressLine -> + if (!addressLine.isEmpty && addressLine == outputAddress.line[addressLineIndex]) { + return false + } + addressLineIndex++ + } + + if ( + ( + !inputAddress.city.isNullOrBlank() && + inputAddress.city == outputAddress.city + ) || + ( + !inputAddress.postalCode.isNullOrBlank() && + inputAddress.postalCode == outputAddress.postalCode + ) || + ( + !inputAddress.district.isNullOrBlank() && + inputAddress.district == outputAddress.district + ) + + ) { + return false + } + return true + } + + /** + * Tests service request PII is replaced + */ + private fun testServiceRequestPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var serviceRequestIndex = 0 + val outputServiceRequests = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputServiceRequest -> + val outputServiceRequest = outputServiceRequests[serviceRequestIndex] + var noteIndex = 0 + inputServiceRequest.note.forEach { inputNote -> + if (!inputNote.text.isNullOrBlank() && + inputNote.text == outputServiceRequest.note[noteIndex].text + ) { + return false + } + noteIndex++ + } + serviceRequestIndex++ + } + return true + } + + /** + * Tests observation PII is replaced + */ + private fun testObservationPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var observationIndex = 0 + val outputObservations = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputObservation -> + val outputObservation = outputObservations[observationIndex] + if (inputObservation.issued != null && inputObservation.issued == outputObservation.issued) { + return false + } + if (inputObservation.effective != null && inputObservation.effective == outputObservation.effective) { + return false + } + var noteIndex = 0 + inputObservation.note.forEach { inputNote -> + if (!inputNote.text.isNullOrBlank() && inputNote.text == outputObservation.note[noteIndex].text) { + return false + } + noteIndex++ + } + + observationIndex++ + } + return true + } + + /** + * Tests specimen PII is replaced + */ + private fun testSpecimenPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var specimenIndex = 0 + val outputSpecimens = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputSpecimen -> + val outputSpecimen = outputSpecimens[specimenIndex] + var noteIndex = 0 + inputSpecimen.note.forEach { inputNote -> + if (inputNote.text != null && inputNote.text == outputSpecimen.note[noteIndex].text) { + return false + } + noteIndex++ + } + specimenIndex++ + } + return true + } + + /** + * Tests organization PII is replaced + */ + private fun testOrganizationPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var organizationIndex = 0 + val outputOrganizations = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputOrganization -> + val outputOrganization = outputOrganizations[organizationIndex] + var addressIndex = 0 + inputOrganization.address.forEach { address -> + if ( + !testAddress(address, outputOrganization.address[addressIndex]) + ) { + return false + } + + addressIndex++ + } + + var telecomIndex = 0 + inputOrganization.telecom.forEach { telecom -> + if (!telecom.value.isNullOrBlank() && + telecom.value == outputOrganization.telecom[telecomIndex].value + ) { + return false + } + telecomIndex++ + } + + var contactIndex = 0 + inputOrganization.contact.forEach { contact -> + val outputContact = outputOrganization.contact[contactIndex] + if (!testName(contact.name, outputContact.name) || + !testAddress(contact.address, outputContact.address) + ) { + return false + } + + telecomIndex = 0 + contact.telecom.forEach { telecom -> + if (!telecom.value.isNullOrBlank() && + telecom.value == outputOrganization.telecom[telecomIndex].value + ) { + return false + } + telecomIndex++ + } + + contactIndex++ + } + organizationIndex++ + } + return true + } + + /** + * Tests practitioner PII is replaced + */ + private fun testPractitionerPIIRemoved(inputBundle: Bundle, outputBundle: Bundle): Boolean { + var practitionerIndex = 0 + val outputPractitioners = outputBundle.entry.map { it.resource }.filterIsInstance() + inputBundle.entry.map { it.resource }.filterIsInstance() + .forEach { inputPractitioner -> + val outputPractitioner = outputPractitioners[practitionerIndex] + var nameIndex = 0 + inputPractitioner.name.forEach { name -> + if (!testName(name, outputPractitioner.name[nameIndex])) { + return false + } + nameIndex++ + } + + var addressIndex = 0 + inputPractitioner.address.forEach { address -> + if (!testAddress(address, outputPractitioner.address[addressIndex])) { + return false + } + + addressIndex++ + } + + var telecomIndex = 0 + inputPractitioner.telecom.forEach { telecom -> + if (!telecom.value.isNullOrBlank() && + telecom.value == outputPractitioner.telecom[telecomIndex].value + ) { + return false + } + telecomIndex++ + } + practitionerIndex++ + } + return true + } + + /** + * Tests PII IDs replaced + */ + private fun testIdsRemoved(inputBundle: Bundle, outputContent: String): Boolean { + PIIRemovalCommands().idPaths.forEach { path -> + if (!testIdRemoved(path, inputBundle, outputContent)) { + return false + } + } + return true + } + + /** + * Tests specific PII id is replaced + */ + fun testIdRemoved(path: String, inputBundle: Bundle, outputcontent: String): Boolean { + FhirPathUtils.evaluate( + null, + inputBundle, + inputBundle, + path + ).forEach { resourceId -> + if (outputcontent.contains(resourceId.primitiveValue().toString())) { + return false + } + } + return true + } +} \ No newline at end of file diff --git a/prime-router/src/main/kotlin/cli/tests/TestReportStream.kt b/prime-router/src/main/kotlin/cli/tests/TestReportStream.kt index 2df46884a30..c20eeffe0ff 100644 --- a/prime-router/src/main/kotlin/cli/tests/TestReportStream.kt +++ b/prime-router/src/main/kotlin/cli/tests/TestReportStream.kt @@ -298,6 +298,7 @@ Examples: companion object { val coolTestList = listOf( + RemovePIITest(), Ping(), SftpcheckTest(), Merge(), diff --git a/prime-router/src/main/kotlin/cli/tests/fakePII.fhir b/prime-router/src/main/kotlin/cli/tests/fakePII.fhir new file mode 100644 index 00000000000..51f4372d48b --- /dev/null +++ b/prime-router/src/main/kotlin/cli/tests/fakePII.fhir @@ -0,0 +1,767 @@ +{ + "resourceType": "Bundle", + "identifier": { + "value": "0bab3f94-feb4-4915-939d-1adc5da201f3" + }, + "type": "message", + "timestamp": "2024-06-05T18:55:01.277Z", + "entry": [ + { + "fullUrl": "MessageHeader/5cbae40a-675e-49d4-b12a-04271de671a6", + "resource": { + "resourceType": "MessageHeader", + "id": "5cbae40a-675e-49d4-b12a-04271de671a6", + "meta": { + "tag": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0103", + "code": "P", + "display": "Production" + } + ] + }, + "eventCoding": { + "system": "http://terminology.hl7.org/CodeSystem/v2-0003", + "code": "R01", + "display": "ORU/ACK - Unsolicited transmission of an observation message" + }, + "destination": [ + { + "name": "PRIME ReportStream", + "endpoint": "https://prime.cdc.gov/api/reports?option=SkipInvalidItems" + } + ], + "sender": { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + }, + "source": { + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/software-binary-id", + "valueString": "d5de310" + }, + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/software-install-date", + "valueInstant": "2024-06-05T15:42:52Z" + }, + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/software-vendor-org", + "valueReference": { + "reference": "Organization/07640c5d-87cd-488b-9343-a226c5166539" + } + } + ], + "software": "PRIME SimpleReport", + "version": "d5de310", + "endpoint": "https://simplereport.gov" + }, + "focus": [ + { + "reference": "Provenance/d0dd19cd-76a0-4e16-ab1d-dc5838247b61" + }, + { + "reference": "DiagnosticReport/0bab3f94-feb4-4915-939d-1adc5da201f3" + } + ] + } + }, + { + "fullUrl": "Provenance/d0dd19cd-76a0-4e16-ab1d-dc5838247b61", + "resource": { + "resourceType": "Provenance", + "id": "d0dd19cd-76a0-4e16-ab1d-dc5838247b61", + "recorded": "2024-06-05T18:55:01.277Z", + "activity": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0003", + "code": "R01", + "display": "ORU/ACK - Unsolicited transmission of an observation message" + } + ] + }, + "agent": [ + { + "who": { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + } + } + ] + } + }, + { + "fullUrl": "DiagnosticReport/0bab3f94-feb4-4915-939d-1adc5da201f3", + "resource": { + "resourceType": "DiagnosticReport", + "id": "0bab3f94-feb4-4915-939d-1adc5da201f3", + "identifier": [ + { + "value": "0bab3f94-feb4-4915-939d-1adc5da201f3" + } + ], + "basedOn": [ + { + "reference": "ServiceRequest/185170f3-4361-48ff-85e1-808a66624470" + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "94531-1" + } + ] + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "effectiveDateTime": "2024-06-05T18:39:58+00:00", + "issued": "2024-06-05T18:55:01+00:00", + "specimen": [ + { + "reference": "Specimen/dc7af370-fc07-4b00-abc7-9b5dd87cf4d2" + } + ], + "result": [ + { + "reference": "Observation/5ab37a34-59f5-421f-92bd-baffaf26bb72" + } + ] + } + }, + { + "fullUrl": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67", + "resource": { + "resourceType": "Patient", + "id": "7c0d1de9-270e-4d9c-a4ec-af92560cec67", + "extension": [ + { + "url": "http://ibm.com/fhir/cdm/StructureDefinition/local-race-cd", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-Race", + "code": "2028-9" + } + ], + "text": "asian" + } + }, + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/ethnic-group", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0189", + "code": "N", + "display": "Not Hispanic or Latino" + } + ], + "text": "Not Hispanic or Latino" + } + } + ], + "identifier": [ + { + "value": "7c0d1de9-270e-4d9c-a4ec-af92560cec67" + } + ], + "name": [ + { + "family": "Granger", + "given": [ + "Hermione" + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "(212) 555 1234", + "use": "mobile" + } + ], + "gender": "female", + "birthDate": "1990-07-21", + "address": [ + { + "line": [ + "53 Buttonwood Ave" + ], + "city": "Brooklyn", + "state": "WA", + "postalCode": "11224", + "country": "USA" + } + ], + "managingOrganization": { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + } + } + }, + { + "fullUrl": "Organization/719ec8ad-cf59-405a-9832-c4065945c130", + "resource": { + "resourceType": "Organization", + "id": "719ec8ad-cf59-405a-9832-c4065945c130", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0301", + "code": "CLIA" + } + ] + }, + "value": "12D4567890" + } + ], + "name": "Testing Lab", + "telecom": [ + { + "system": "phone", + "value": "(530) 867 5309", + "use": "work" + } + ], + "address": [ + { + "line": [ + "123 Beach Way" + ], + "city": "Denver", + "state": "WA", + "postalCode": "80210", + "country": "USA" + } + ] + } + }, + { + "fullUrl": "Practitioner/ee29ccf5-631d-4b35-a6d4-30a61c0eb8d9", + "resource": { + "resourceType": "Practitioner", + "id": "ee29ccf5-631d-4b35-a6d4-30a61c0eb8d9", + "identifier": [ + { + "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1245319599" + } + ], + "name": [ + { + "family": "McTester", + "given": [ + "Phil" + ] + } + ], + "telecom": [ + { + "system": "phone", + "value": "(530) 867 5309", + "use": "work" + } + ], + "address": [ + { + "line": [ + "321 Ocean Drive" + ], + "city": "Denver", + "state": "WA", + "postalCode": "80210", + "country": "USA" + } + ] + } + }, + { + "fullUrl": "Specimen/dc7af370-fc07-4b00-abc7-9b5dd87cf4d2", + "resource": { + "resourceType": "Specimen", + "id": "dc7af370-fc07-4b00-abc7-9b5dd87cf4d2", + "identifier": [ + { + "value": "80ed36a0-4bd1-42c3-bb56-81ea4ac1e75a" + } + ], + "type": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "258500001" + } + ], + "text": "Nasopharyngeal swab" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "receivedTime": "2024-06-05T18:39:58+00:00", + "collection": { + "collectedDateTime": "2024-06-05T18:39:58+00:00", + "bodySite": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "87100004" + } + ], + "text": "Topography unknown (body structure)" + } + } + } + }, + { + "fullUrl": "ServiceRequest/185170f3-4361-48ff-85e1-808a66624470", + "resource": { + "resourceType": "ServiceRequest", + "id": "185170f3-4361-48ff-85e1-808a66624470", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/order-control", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0119", + "code": "RE" + } + ] + } + }, + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/order-effective-date", + "valueDateTime": "2024-06-05T18:54:58+00:00" + } + ], + "status": "completed", + "intent": "order", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "94531-1" + } + ] + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "requester": { + "reference": "PractitionerRole/57a1a000-16e5-461a-930a-2e4779944bc2" + }, + "performer": [ + { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + } + ], + "supportingInfo": [ + { + "reference": "Observation/dd5369b2-91e7-32d9-8c7f-884cad6b0391" + }, + { + "reference": "Observation/fdf748f4-b187-31c6-b08d-334afc1c6a49" + }, + { + "reference": "Observation/4c669397-3b8b-3448-a7c8-d2fb8c5afde5" + }, + { + "reference": "Observation/4456d162-bfd8-37f3-aafe-1e6444c53934" + }, + { + "reference": "Observation/0042a1cb-8473-3181-9b65-fcb08dc112a3" + } + ] + } + }, + { + "fullUrl": "Device/d303372c-70cb-46b7-bf74-23f4dc91e661", + "resource": { + "resourceType": "Device", + "id": "d303372c-70cb-46b7-bf74-23f4dc91e661", + "identifier": [ + { + "type": { + "coding": [ + { + "code": "MNI" + } + ] + } + } + ], + "manufacturer": "Access Bio, Inc.", + "deviceName": [ + { + "name": "CareStart COVID-19 MDx RT-PCR", + "type": "model-name" + } + ] + } + }, + { + "fullUrl": "PractitionerRole/57a1a000-16e5-461a-930a-2e4779944bc2", + "resource": { + "resourceType": "PractitionerRole", + "id": "57a1a000-16e5-461a-930a-2e4779944bc2", + "practitioner": { + "reference": "Practitioner/ee29ccf5-631d-4b35-a6d4-30a61c0eb8d9" + }, + "organization": { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + } + } + }, + { + "fullUrl": "Organization/07640c5d-87cd-488b-9343-a226c5166539", + "resource": { + "resourceType": "Organization", + "id": "07640c5d-87cd-488b-9343-a226c5166539", + "name": "SimpleReport" + } + }, + { + "fullUrl": "Observation/5ab37a34-59f5-421f-92bd-baffaf26bb72", + "resource": { + "resourceType": "Observation", + "id": "5ab37a34-59f5-421f-92bd-baffaf26bb72", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "94500-6" + } + ], + "text": "COVID-19" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "issued": "2024-06-05T18:54:58.594Z", + "performer": [ + { + "reference": "Organization/719ec8ad-cf59-405a-9832-c4065945c130" + } + ], + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "260373001", + "display": "Detected" + } + ] + }, + "interpretation": [ + { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v2-0078", + "code": "A", + "display": "Abnormal" + } + ] + } + ], + "method": { + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/testkit-name-id", + "valueCoding": { + "code": "CareStart COVID-19 MDx RT-PCR_Access Bio, Inc." + } + } + ], + "coding": [ + { + "display": "CareStart COVID-19 MDx RT-PCR" + } + ] + }, + "specimen": { + "reference": "Specimen/dc7af370-fc07-4b00-abc7-9b5dd87cf4d2" + }, + "device": { + "reference": "Device/d303372c-70cb-46b7-bf74-23f4dc91e661" + } + } + }, + { + "fullUrl": "Observation/dd5369b2-91e7-32d9-8c7f-884cad6b0391", + "resource": { + "resourceType": "Observation", + "id": "dd5369b2-91e7-32d9-8c7f-884cad6b0391", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel" + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "95419-8", + "display": "Has symptoms related to condition of interest", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ], + "text": "Has symptoms related to condition of interest" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/ValueSet/v2-0136", + "code": "Y", + "display": "Yes" + } + ] + } + } + }, + { + "fullUrl": "Observation/fdf748f4-b187-31c6-b08d-334afc1c6a49", + "resource": { + "resourceType": "Observation", + "id": "fdf748f4-b187-31c6-b08d-334afc1c6a49", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "11368-8", + "display": "Illness or injury onset date and time" + } + ], + "text": "Illness or injury onset date and time" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "valueDateTime": "2024-06-01" + } + }, + { + "fullUrl": "Observation/4c669397-3b8b-3448-a7c8-d2fb8c5afde5", + "resource": { + "resourceType": "Observation", + "id": "4c669397-3b8b-3448-a7c8-d2fb8c5afde5", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "82810-3", + "display": "Pregnancy status" + } + ], + "text": "Pregnancy status" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "60001007", + "display": "Not pregnant", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ] + } + } + }, + { + "fullUrl": "Observation/4456d162-bfd8-37f3-aafe-1e6444c53934", + "resource": { + "resourceType": "Observation", + "id": "4456d162-bfd8-37f3-aafe-1e6444c53934", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "95418-0", + "display": "Employed in a healthcare setting" + } + ], + "text": "Employed in a healthcare setting" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/ValueSet/v2-0136", + "code": "Y", + "display": "Yes" + } + ] + } + } + }, + { + "fullUrl": "Observation/0042a1cb-8473-3181-9b65-fcb08dc112a3", + "resource": { + "resourceType": "Observation", + "id": "0042a1cb-8473-3181-9b65-fcb08dc112a3", + "identifier": [ + { + "use": "official", + "type": { + "coding": [ + { + "system": "http://loinc.org", + "code": "81959-9", + "display": "Public health laboratory ask at order entry panel", + "extension": [ + { + "url": "https://reportstream.cdc.gov/fhir/StructureDefinition/condition-code", + "valueCoding": { + "system": "ReportStream", + "code": "AOE", + "display": "Ask at order entry question" + } + } + ] + } + ] + } + } + ], + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "95421-4", + "display": "Resides in a congregate care setting" + } + ], + "text": "Resides in a congregate care setting" + }, + "subject": { + "reference": "Patient/7c0d1de9-270e-4d9c-a4ec-af92560cec67" + }, + "valueCodeableConcept": { + "coding": [ + { + "system": "http://terminology.hl7.org/ValueSet/v2-0136", + "code": "Y", + "display": "Yes" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt b/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt index 967d7179114..5fcfcaeb1f1 100644 --- a/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt +++ b/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt @@ -15,8 +15,10 @@ import org.hl7.fhir.r4.model.Device import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.utils.FHIRPathUtilityClasses.FunctionDetails +import java.time.LocalDate import java.util.Date import java.util.UUID +import java.util.concurrent.ThreadLocalRandom import kotlin.math.abs import kotlin.random.Random @@ -194,7 +196,7 @@ class CustomFhirPathFunctions : FhirPathFunctions { if (parameters.size != 2) { throw SchemaException( "Must call the getFakeValueForElement function for city or postal code with" + - " a state specified." + " a state specified." ) } } @@ -213,7 +215,7 @@ class CustomFhirPathFunctions : FhirPathFunctions { metadata ) GeoData.DataTypes.TESTING_LAB -> "Any lab USA" - GeoData.DataTypes.SENDER_IDENTIFIER -> UUID.randomUUID().toString() + GeoData.DataTypes.UUID -> UUID.randomUUID().toString() GeoData.DataTypes.FACILITY_NAME -> "Any facility USA" GeoData.DataTypes.NAME_OF_SCHOOL -> "Any Fake School" GeoData.DataTypes.REFERENCE_RANGE -> randomChoice("", "Normal", "Abnormal", "Negative") @@ -228,9 +230,9 @@ class CustomFhirPathFunctions : FhirPathFunctions { metadata ) GeoData.DataTypes.EQUIPMENT_MODEL_NAME -> randomChoice( - "LumiraDx SARS-CoV-2 Ag Test", - "BD Veritor System for Rapid Detection of SARS-CoV-2" - ) + "LumiraDx SARS-CoV-2 Ag Test", + "BD Veritor System for Rapid Detection of SARS-CoV-2" + ) GeoData.DataTypes.TEST_PERFORMED_CODE -> randomChoice( "95209-3", "94558-4" @@ -239,14 +241,18 @@ class CustomFhirPathFunctions : FhirPathFunctions { GeoData.DataTypes.BLANK -> "" GeoData.DataTypes.TEXT_OR_BLANK -> randomChoice("I am some random text", "") GeoData.DataTypes.NUMBER -> Random.nextInt().toString().replace("-", "") - GeoData.DataTypes.DATE -> DateUtilities.getDateAsFormattedString( - getRandomDate().toInstant(), - DateUtilities.datePattern - ) - GeoData.DataTypes.BIRTHDAY -> DateUtilities.getDateAsFormattedString( - getRandomDate().toInstant(), - DateUtilities.datePattern - ) + GeoData.DataTypes.DATE -> { + val minDay: Long = LocalDate.of(2000, 1, 1).toEpochDay() + val maxDay: Long = LocalDate.of(2023, 12, 31).toEpochDay() + val randomDay = ThreadLocalRandom.current().nextLong(minDay, maxDay) + LocalDate.ofEpochDay(randomDay).toString() + } + GeoData.DataTypes.BIRTHDAY -> { + val minDay: Long = LocalDate.of(1950, 1, 1).toEpochDay() + val maxDay: Long = LocalDate.of(2023, 12, 31).toEpochDay() + val randomDay = ThreadLocalRandom.current().nextLong(minDay, maxDay) + LocalDate.ofEpochDay(randomDay).toString() + } GeoData.DataTypes.DATETIME -> DateUtilities.getDateAsFormattedString( getRandomDate().toInstant(), DateUtilities.datetimePattern @@ -259,7 +265,14 @@ class CustomFhirPathFunctions : FhirPathFunctions { GeoData.DataTypes.ID_SSN -> Faker().idNumber().validSvSeSsn() GeoData.DataTypes.ID_NPI -> NPIUtilities.generateRandomNPI(Faker()) GeoData.DataTypes.STREET -> Faker().address().streetAddress() - GeoData.DataTypes.PERSON_NAME -> Faker().name().fullName() + GeoData.DataTypes.PERSON_GIVEN_NAME -> { + val fullName = Faker().name().fullName() + fullName.substring(0, fullName.indexOf(" ")) + } + GeoData.DataTypes.PERSON_FAMILY_NAME -> { + val fullName = Faker().name().fullName() + fullName.substring(fullName.lastIndexOf(' '), fullName.length) + } GeoData.DataTypes.TELEPHONE -> Faker().numerify("12#########") GeoData.DataTypes.EMAIL -> Faker().name().fullName() .replace(" ", "") @@ -269,7 +282,7 @@ class CustomFhirPathFunctions : FhirPathFunctions { GeoData.DataTypes.PROCESSING_MODE_CODE -> "P" GeoData.DataTypes.VALUE_TYPE -> "CWE" GeoData.DataTypes.TEST_RESULT -> randomChoice("260373001", "260415000", "419984006") - GeoData.DataTypes.PATIENT_STREET_ADDRESS_2 -> randomChoice("Apt. 305", "Suite 22", "Building 2") + GeoData.DataTypes.STREET_ADDRESS_2 -> randomChoice("Apt. 305", "Suite 22", "Building 2") GeoData.DataTypes.ID_NUMBER -> randomChoice("ABC123", "123LKJ", "bjh098") GeoData.DataTypes.SOURCE_OF_COMMENT -> randomChoice("L", "O", "P") } diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt index 14d251f4a42..8720b3ed611 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/FhirTransformer.kt @@ -1,5 +1,6 @@ package gov.cdc.prime.router.fhirengine.translation.hl7 +import fhirengine.engine.CustomFhirPathFunctions import gov.cdc.prime.router.azure.BlobAccess import gov.cdc.prime.router.common.Environment import gov.cdc.prime.router.fhirengine.translation.hl7.schema.ConfigSchemaElementProcessingException @@ -62,7 +63,7 @@ class FhirTransformer( schema: FhirTransformSchema, bundle: Bundle, focusResource: Base, - context: CustomContext = CustomContext(bundle, focusResource), + context: CustomContext = CustomContext(bundle, focusResource, customFhirFunctions = CustomFhirPathFunctions()), debug: Boolean = false, ) { val logLevel = if (debug) Level.INFO else Level.DEBUG @@ -367,9 +368,9 @@ class FhirTransformer( elementsToUpdate.forEach { penultimateElement -> val property = penultimateElement.getNamedProperty(propertyName) val newValue = FhirBundleUtils.convertFhirType(value, value.fhirType(), property.typeCode, logger) - penultimateElement.setProperty(propertyName, newValue.copy()) + penultimateElement.setProperty(propertyName, newValue.copy()) + } } - } private fun createMissingElementsInBundleProperty( fhirPath: String, diff --git a/prime-router/src/main/kotlin/metadata/GeoData.kt b/prime-router/src/main/kotlin/metadata/GeoData.kt index 50620c3672d..7f01c409dc5 100644 --- a/prime-router/src/main/kotlin/metadata/GeoData.kt +++ b/prime-router/src/main/kotlin/metadata/GeoData.kt @@ -28,6 +28,17 @@ object GeoData { var filters = tableRef?.FilterBuilder() ?: error("Could not find table '$tableRef'\"") filters = filters.equalsIgnoreCase(ColumnNames.STATE_ABBR.columnName, state) val uniqueValues = filters.findAllUnique(column.columnName) + if (uniqueValues.isEmpty()) { + // bad data passed, just return default + return when (column) { + ColumnNames.STATE_FIPS -> "12345" + ColumnNames.STATE -> state + ColumnNames.STATE_ABBR -> state + ColumnNames.ZIP_CODE -> "98765" + ColumnNames.COUNTY -> "Multnomah" + ColumnNames.CITY -> "Portland" + } + } return uniqueValues[Random().nextInt(uniqueValues.size)] } @@ -47,7 +58,7 @@ object GeoData { CITY("city"), POSTAL_CODE("postal_code"), TESTING_LAB("testing_lab"), - SENDER_IDENTIFIER("sender_identifier"), + UUID("sender_identifier"), FACILITY_NAME("facility_name"), NAME_OF_SCHOOL("name_of_school"), REFERENCE_RANGE("reference_range"), @@ -78,12 +89,13 @@ object GeoData { ID_SSN("id_ssn"), ID_NPI("id_npi"), STREET("street"), - PERSON_NAME("person_name"), + PERSON_GIVEN_NAME("person_given_name"), + PERSON_FAMILY_NAME("person_family_name"), TELEPHONE("telephone"), EMAIL("email"), BIRTHDAY("birthday"), ID_NUMBER("id_number"), - PATIENT_STREET_ADDRESS_2("patient_street_address_2"), + STREET_ADDRESS_2("patient_street_address_2"), SOURCE_OF_COMMENT("source_of_comment"), } } \ No newline at end of file diff --git a/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-address.yml b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-address.yml new file mode 100644 index 00000000000..52117eab502 --- /dev/null +++ b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-address.yml @@ -0,0 +1,18 @@ +elements: + # removing the street address is more complicated because it is a list so we will do this in code + + - name: pii-removal-street-address2 + value: [ 'getFakeValueForElement("STREET_ADDRESS_2")' ] + bundleProperty: '%resource.extension(%`rsext-xad-address`).extension.where(url = "XAD.2").value' + + - name: pii-removal-city + value: [ 'getFakeValueForElement("CITY",%resource.state)' ] + bundleProperty: '%resource.city' + + - name: pii-removal-zip + value: [ 'getFakeValueForElement("POSTAL_CODE", %resource.state)' ] + bundleProperty: '%resource.postalCode' + + - name: pii-removal-county + value: [ 'getFakeValueForElement("COUNTY", %resource.state)' ] + bundleProperty: '%resource.district' \ No newline at end of file diff --git a/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-enrichment.yml b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-enrichment.yml new file mode 100644 index 00000000000..951abf18684 --- /dev/null +++ b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-enrichment.yml @@ -0,0 +1,56 @@ +elements: +# telecoms will be removed via code since they are lists and this is more complicated + - name: pii-removal-patient-name + resource: 'Bundle.entry.resource.ofType(Patient).name' + resourceIndex: nameIndex + schema: classpath:/metadata/fhir_transforms/common/remove-pii-name.yml + + - name: pii-removal-patient-dob + resource: 'Bundle.entry.resource.ofType(Patient)' + value: [ 'getFakeValueForElement("BIRTHDAY")' ] + bundleProperty: '%resource.birthDate' + + - name: pii-removal-patient-address + resource: 'Bundle.entry.resource.ofType(Patient).address' + resourceIndex: addressIndex + schema: classpath:/metadata/fhir_transforms/common/remove-pii-address.yml + + - name: pii-removal-ordering-provider-name + resource: 'Bundle.entry.resource.ofType(ServiceRequest).requester.resolve().practitioner.resolve().name' + resourceIndex: nameIndex + schema: classpath:/metadata/fhir_transforms/common/remove-pii-name.yml + + - name: pii-removal-ordering-facility-address + resource: 'Bundle.entry.resource.ofType(ServiceRequest).requester.resolve().organization.resolve().address' + resourceIndex: addressIndex + schema: classpath:/metadata/fhir_transforms/common/remove-pii-address.yml + + - name: pii-removal-service-request-note + resource: 'Bundle.entry.resource.ofType(ServiceRequest)' + value: [ 'getFakeValueForElement("OTHER_TEXT")' ] + bundleProperty: '%resource.note.text' + + - name: pii-removal-observation-result-date-issued + resource: 'Bundle.entry.resource.ofType(Observation)' + value: [ 'getFakeValueForElement("DATETIME")' ] + bundleProperty: '%resource.issued' + + - name: pii-removal-observation-result-date-effective + resource: 'Bundle.entry.resource.ofType(Observation)' + value: [ 'getFakeValueForElement("DATETIME")' ] + bundleProperty: '%resource.effective[x]' + + - name: pii-removal-observation-note + resource: 'Bundle.entry.resource.ofType(Observation)' + value: [ 'getFakeValueForElement("OTHER_TEXT")' ] + bundleProperty: '%resource.note.text' + + - name: pii-removal-specimen-note + resource: 'Bundle.entry.resource.ofType(Specimen)' + value: [ 'getFakeValueForElement("OTHER_TEXT")' ] + bundleProperty: '%resource.note.text' + + + + + diff --git a/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-name.yml b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-name.yml new file mode 100644 index 00000000000..4968f5eac5a --- /dev/null +++ b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-name.yml @@ -0,0 +1,10 @@ +elements: + - name: pii-removal-last-name + value: [ 'getFakeValueForElement("PERSON_FAMILY_NAME")' ] + bundleProperty: '%resource.family' + + # removing a given name is more complicated because it is a list so we will do this in code + + - name: pii-removal-middle-name + value: [ 'getFakeValueForElement("PERSON_GIVEN_NAME")' ] + bundleProperty: '%resource.extension(%`rsext-xpn-human-name`).extension.where(url="XPN.3").value' \ No newline at end of file diff --git a/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-telecom.yml b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-telecom.yml new file mode 100644 index 00000000000..fef6b43d8a6 --- /dev/null +++ b/prime-router/src/main/resources/metadata/fhir_transforms/common/remove-pii-telecom.yml @@ -0,0 +1,15 @@ +elements: + - name: pii-removal-phone-area-code + condition: "%resource.where(system = 'phone')" + value: [ 'getFakeValueForElement("TELEPHONE").substring(0,3)' ] + bundleProperty: '%resource.extension(%`ext-contactpoint-area`).value' + + - name: pii-removal-local-phone + condition: "%resource.where(system = 'phone')" + value: [ 'getFakeValueForElement("TELEPHONE")' ] + bundleProperty: '%resource.value' + + - name: pii-removal-email + condition: "%resource.where(system = 'email')" + value: [ 'getFakeValueForElement("EMAIL")' ] + bundleProperty: '%resource.value' \ No newline at end of file diff --git a/prime-router/src/test/kotlin/fhirengine/engine/CustomFhirPathFunctionTest.kt b/prime-router/src/test/kotlin/fhirengine/engine/CustomFhirPathFunctionTest.kt index 5c002daf3c0..95f92e40785 100644 --- a/prime-router/src/test/kotlin/fhirengine/engine/CustomFhirPathFunctionTest.kt +++ b/prime-router/src/test/kotlin/fhirengine/engine/CustomFhirPathFunctionTest.kt @@ -161,7 +161,7 @@ class CustomFhirPathFunctionTest { @Test fun `test get fake value for element function`() { - // Fails if city, county, or postal code and no state + // Fails if city, county, or postal code and no state assertFailure { CustomFhirPathFunctions().getFakeValueForElement( mutableListOf(mutableListOf(StringType("CITY"))),