Skip to content

Commit

Permalink
Handle multiple phone number formats in search (#2232)
Browse files Browse the repository at this point in the history
* Scattershot approach to phone numbers

* Update integration test copy

* Add phone number search to API side

* Name test

* Update copy on e2e test
  • Loading branch information
bamader authored Jul 30, 2024
1 parent c7222fd commit b68f6d7
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 1 deletion.
32 changes: 31 additions & 1 deletion containers/tefca-viewer/e2e/query_workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test.describe("querying with the TryTEFCA viewer", () => {

await page.getByLabel("First Name").fill("Ellie");
await page.getByLabel("Last Name").fill("Williams");
await page.getByLabel("Date of Birth").fill("2030-07-07");
await page.getByLabel("Date of Birth").fill("2019-07-07");
await page.getByLabel("Medical Record Number").fill("TLOU1TLOU2");
await page.getByLabel("FHIR Server").selectOption("HELIOS Meld: Direct");
await page.getByRole("button", { name: "Search for patient" }).click();
Expand All @@ -123,4 +123,34 @@ test.describe("querying with the TryTEFCA viewer", () => {
page.getByRole("heading", { name: "Search for a Patient" }),
).toBeVisible();
});

test("query using form-fillable demo patient by phone number", async ({
page,
}) => {
await page.getByRole("button", { name: "Go to the demo" }).click();
await page.getByRole("button", { name: "Next" }).click();

await page
.getByLabel("Query", { exact: true })
.selectOption("demo-sti-syphilis");
await page
.getByLabel("Patient", { exact: true })
.selectOption("demo-sti-syphilis-positive");

// Delete last name and MRN to force phone number as one of the 3 fields
await page.getByLabel("Last Name").clear();
await page.getByLabel("Medical Record Number").clear();

// Among verification, make sure phone number is right
await page.getByRole("button", { name: "Search for patient" }).click();
await expect(
page.getByRole("heading", { name: "Query Results" }),
).toBeVisible();
await expect(page.getByText("Patient Name")).toBeVisible();
await expect(page.getByText("Veronica Anne Blackstone")).toBeVisible();
await expect(page.getByText("Contact")).toBeVisible();
await expect(page.getByText("937-379-3497")).toBeVisible();
await expect(page.getByText("Patient Identifiers")).toBeVisible();
await expect(page.getByText("34972316")).toBeVisible();
});
});
1 change: 1 addition & 0 deletions containers/tefca-viewer/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default defineConfig({
command: "docker compose up",
port: 3000,
timeout: 120 * 1000,
reuseExistingServer: true,
},

/* Hook to ensure Docker is shut down after tests or on error */
Expand Down
36 changes: 36 additions & 0 deletions containers/tefca-viewer/src/app/api/query/parsing-service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use server";

import { Patient } from "fhir/r4";
import { FormatPhoneAsDigits } from "@/app/utils";

export type PatientIdentifiers = {
first_name?: string;
last_name?: string;
dob?: string;
mrn?: string;
phone?: string;
};

/**
Expand Down Expand Up @@ -41,6 +43,20 @@ export async function parsePatientDemographics(
identifiers.mrn = mrnIdentifiers[0];
}

// Extract phone numbers from patient telecom arrays
let phoneNumbers = await parsePhoneNumbers(patient);
if (phoneNumbers) {
// Strip formatting so the query service can generate options
phoneNumbers = phoneNumbers
.map((phone) => FormatPhoneAsDigits(phone || ""))
.filter((phone) => phone !== "");
if (phoneNumbers.length == 1) {
identifiers.phone = phoneNumbers[0];
} else if (phoneNumbers.length > 1) {
identifiers.phone = phoneNumbers.join(";");
}
}

return identifiers;
}

Expand All @@ -63,3 +79,23 @@ export async function parseMRNs(
return mrnIdentifiers.map((id) => id.value);
}
}

/**
* Helper function that extracts all applicable phone numbers from a patient resource
* and returns them as a list of strings, without changing the input formatting
* of the phone numbers.
* @param patient A FHIR Patient resource.
* @returns A list of phone numbers, or undefined if the patient has no telecom.
*/
export async function parsePhoneNumbers(
patient: Patient,
): Promise<(string | undefined)[] | undefined> {
if (patient.telecom) {
const phoneNumbers = patient.telecom.filter(
(contactPoint) =>
contactPoint.system === "phone" ||
["home", "work", "mobile"].includes(contactPoint.use || ""),
);
return phoneNumbers.map((contactPoint) => contactPoint.value);
}
}
1 change: 1 addition & 0 deletions containers/tefca-viewer/src/app/api/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export async function POST(request: NextRequest) {
}),
...(PatientIdentifiers.dob && { dob: PatientIdentifiers.dob }),
...(PatientIdentifiers.mrn && { mrn: PatientIdentifiers.mrn }),
...(PatientIdentifiers.phone && { phone: PatientIdentifiers.phone }),
};

const UseCaseQueryResponse: QueryResponse =
Expand Down
47 changes: 47 additions & 0 deletions containers/tefca-viewer/src/app/query-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type UseCaseQueryRequest = {
last_name?: string;
dob?: string;
mrn?: string;
phone?: string;
};

const UseCaseQueryMap: {
Expand All @@ -58,6 +59,41 @@ const UseCaseQueryMap: {
// Expected responses from the FHIR server
export type UseCaseQueryResponse = Awaited<ReturnType<typeof UseCaseQuery>>;

const FORMATS_TO_SEARCH: string[] = [
"$1$2$3",
"$1-$2-$3",
"$1+$2+$3",
"($1)+$2+$3",
"($1)-$2-$3",
"($1)$2-$3",
"1($1)$2-$3",
];

/**
* @todo Once the country code box is created on the search form, we'll
* need to use that value to act as a kind of switch logic here to figure
* out which formats we should be using.
* Helper function to transform a cleaned, digit-only representation of
* a phone number into multiple possible formatting options of that phone
* number. If the given number has fewer than 10 digits, or contains any
* delimiters, no formatting is performed and only the given number is
* used.
* @param phone A digit-only representation of a phone number.
* @returns An array of formatted phone numbers.
*/
export async function GetPhoneQueryFormats(phone: string) {
// Digit-only phone numbers will resolve as actual numbers
if (isNaN(Number(phone)) || phone.length != 10) {
const strippedPhone = phone.replace(" ", "+");
return [strippedPhone];
}
// Map the phone number into each format we want to check
const possibleFormats: string[] = FORMATS_TO_SEARCH.map((fmt) => {
return phone.replace(/(\d{3})(\d{3})(\d{4})/gi, fmt);
});
return possibleFormats;
}

/**
* Query a FHIR server for a patient based on demographics provided in the request. If
* a patient is found, store in the queryResponse object.
Expand Down Expand Up @@ -85,6 +121,17 @@ async function patientQuery(
if (request.mrn) {
query += `identifier=${request.mrn}&`;
}
if (request.phone) {
// We might have multiple phone numbers if we're coming from the API
// side, since we parse *all* telecom structs
const phonesToSearch = request.phone.split(";");
let phonePossibilities: string[] = [];
for (const phone of phonesToSearch) {
const possibilities = await GetPhoneQueryFormats(phone);
phonePossibilities.push(...possibilities);
}
query += `phone=${phonePossibilities.join(",")}&`;
}

const response = await fhirClient.get(query);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
UseCaseQueryRequest,
} from "../../query-service";
import { Mode } from "../page";
import { FormatPhoneAsDigits } from "@/app/utils";
import { useSearchParams } from "next/navigation";

interface SearchFormProps {
Expand Down Expand Up @@ -98,13 +99,15 @@ const SearchForm: React.FC<SearchFormProps> = ({
}
event.preventDefault();
setLoading(true);

const originalRequest = {
first_name: firstName,
last_name: lastName,
dob: dob,
mrn: mrn,
fhir_server: fhirServer,
use_case: useCase,
phone: FormatPhoneAsDigits(phone),
};
setOriginalRequest(originalRequest);
const queryResponse = await UseCaseQuery(originalRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@
"value": "+18184195968",
"use": "home"
},
{
"system": "phone",
"value": "123-456-7890",
"use": "mobile"
},
{
"system": "email",
"value": "[email protected]"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,20 @@ describe("POST Query FHIR Server", () => {
"Invalid fhir_server. Please provide a valid fhir_server. Valid fhir_servers include HELIOS Meld: Direct,HELIOS Meld: eHealthExchange,JMC Meld: Direct,JMC Meld: eHealthExchange,Public HAPI: eHealthExchange,OpenEpic: eHealthExchange,CernerHelios: eHealthExchange.",
);
});

it("should return a legitimate FHIR bundle if the query is successful", async () => {
const request = {
json: async () => {
return PatientResource;
},
nextUrl: {
searchParams: new URLSearchParams(
"use_case=social-determinants&fhir_server=HELIOS Meld: Direct",
),
},
};
const response = await POST(request as any);
const body = await response.json();
expect(body.resourceType).toBe("Bundle");
});
});
27 changes: 27 additions & 0 deletions containers/tefca-viewer/src/app/tests/unit/query-service.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { GetPhoneQueryFormats } from "@/app/query-service";

describe("GetPhoneQueryFormats", () => {
it("should fail gracefully on partial phone number inputs", async () => {
const partialPhone = "456 7890";
const expectedResult = ["456+7890"];
expect(await GetPhoneQueryFormats(partialPhone)).toEqual(expectedResult);
});
it("should fail gracefully on given phones with separators remaining", async () => {
const phoneWithStuff = "+44.202.456.7890";
const expectedResult = ["+44.202.456.7890"];
expect(await GetPhoneQueryFormats(phoneWithStuff)).toEqual(expectedResult);
});
it("should fully process ten-digit input strings", async () => {
const inputPhone = "1234567890";
const expectedResult = [
"1234567890",
"123-456-7890",
"123+456+7890",
"(123)+456+7890",
"(123)-456-7890",
"(123)456-7890",
"1(123)456-7890",
];
expect(await GetPhoneQueryFormats(inputPhone)).toEqual(expectedResult);
});
});
38 changes: 38 additions & 0 deletions containers/tefca-viewer/src/app/tests/unit/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { FormatPhoneAsDigits } from "@/app/utils";

describe("FormatPhoneAsDigits", () => {
it("should handle dashes, spacecs, and dot delimiters", () => {
const dashInput = "123-456-7890";
const expectedResult = "1234567890";
expect(FormatPhoneAsDigits(dashInput)).toEqual(expectedResult);

const spaceInput = "123 456 7890";
expect(FormatPhoneAsDigits(spaceInput)).toEqual(expectedResult);

const dotInput = "123.456.7890";
expect(FormatPhoneAsDigits(dotInput)).toEqual(expectedResult);
});
it("should handle parentheticals around area codes", () => {
const expectedResult = "1234567890";
let parentheticalInput = "(123) 456 7890";
expect(FormatPhoneAsDigits(parentheticalInput)).toEqual(expectedResult);

parentheticalInput = "(123)456-7890";
expect(FormatPhoneAsDigits(parentheticalInput)).toEqual(expectedResult);
});
it("should handle extraneous white spaces regardless of position", () => {
const expectedResult = "1234567890";
const weirdSpaceNumber = " 123 - 456 7 8 9 0 ";
expect(FormatPhoneAsDigits(weirdSpaceNumber)).toEqual(expectedResult);
});
it("should gracefully fail if provided a partial number", () => {
const givenPhone = "456-7890";
const expectedResult = "456-7890";
expect(FormatPhoneAsDigits(givenPhone)).toEqual(expectedResult);
});
it("should gracefully fail if given a country code", () => {
const givenPhone = "+44 202 555 8736";
const expectedResult = "+44 202 555 8736";
expect(FormatPhoneAsDigits(givenPhone)).toEqual(expectedResult);
});
});
22 changes: 22 additions & 0 deletions containers/tefca-viewer/src/app/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,28 @@ import { createContext, ReactNode, useState } from "react";
import React from "react";
import classNames from "classnames";

/**
* @todo Add country code form box on search form
* @todo Update documentation here once that's done to reflect switch
* logic of country code
*
* Helper function to strip out non-digit characters from a phone number
* entered into the search tool. Right now, we're not going to worry about
* country code or international numbers, so we'll just make an MVP
* assumption of 10 regular digits.
* @param givenPhone The original phone number the user typed.
* @returns The phone number as a pure digit string. If the cleaned number
* is fewer than 10 digits, just return the original.
*/
export function FormatPhoneAsDigits(givenPhone: string) {
// Start by getting rid of all existing separators for a clean slate
const newPhone: string = givenPhone.replace(/\D/g, "");
if (newPhone.length != 10) {
return givenPhone;
}
return newPhone;
}

export interface DisplayData {
title: string;
value?: string | React.JSX.Element | React.JSX.Element[];
Expand Down

0 comments on commit b68f6d7

Please sign in to comment.