diff --git a/query-connector/e2e/alternate_queries.spec.ts b/query-connector/e2e/alternate_queries.spec.ts index fb798a842..2f0b3c055 100644 --- a/query-connector/e2e/alternate_queries.spec.ts +++ b/query-connector/e2e/alternate_queries.spec.ts @@ -42,9 +42,8 @@ test.describe("alternate queries with the Query Connector", () => { await expect(page.getByText(TEST_PATIENT.MRN)).toBeVisible(); }); - test("social determinants query with generalized function", async ({ - page, - }) => { + // test("social determinants query with generalized function", async ({ + test("cancer query with generalized function", async ({ page }) => { await page.getByRole("button", { name: "Go to the demo" }).click(); await page.getByRole("button", { name: "Fill fields" }).click(); await page.getByRole("button", { name: "Search for patient" }).click(); @@ -54,7 +53,8 @@ test.describe("alternate queries with the Query Connector", () => { await expect( page.getByRole("heading", { name: "Select a query" }), ).toBeVisible(); - await page.getByTestId("Select").selectOption("social-determinants"); + // await page.getByTestId("Select").selectOption("social-determinants"); + await page.getByTestId("Select").selectOption("cancer"); await page.getByRole("button", { name: "Submit" }).click(); await expect(page.getByText("Loading")).toHaveCount(0, { timeout: 10000 }); diff --git a/query-connector/src/app/constants.ts b/query-connector/src/app/constants.ts index a2dc9fe02..da12272b0 100644 --- a/query-connector/src/app/constants.ts +++ b/query-connector/src/app/constants.ts @@ -40,10 +40,11 @@ export const demoQueryOptions = [ { value: "chlamydia", label: "Chlamydia case investigation" }, { value: "gonorrhea", label: "Gonorrhea case investigation" }, { value: "newborn-screening", label: "Newborn screening follow-up" }, - { - value: "social-determinants", - label: "Gather social determinants of health", - }, + // Temporarily remove social determinants + // { + // value: "social-determinants", + // label: "Gather social determinants of health", + // }, { value: "syphilis", label: "Syphilis case investigation" }, ]; @@ -386,3 +387,18 @@ export function isFhirResource(resource: unknown): resource is FhirResource { "resourceType" in resource ); } + +// The value for "concept version" (sometimes) exists under "expansion" in the VSAC FHIR +// response. This is similar, but different in some subtle ways from the "compose.include" +// path that we're currently using to grab concept information. Although we could +// grab and parse this information from the FHIR response, it would involve +// changing our data model to store information that we think is a "nice to +// have", which is only available sometimes. As a result, we're purposefully +// leaving this blank until we can clean up the migration schema to drop these columns +export const INTENTIONAL_EMPTY_STRING_FOR_CONCEPT_VERSION = ""; + +// Originally, the column in the concept table was set up to maintain backwards e +//compatibility with the ICD-9 codes that the team is deciding not to support after +// we clean up the DB migration. Leaving these in until we can clean these up +// in the migration schema +export const INTENTIONAL_EMPTY_STRING_FOR_GEM_CODE = ""; diff --git a/query-connector/src/app/database-service.ts b/query-connector/src/app/database-service.ts index 8939e5b85..610cc634f 100644 --- a/query-connector/src/app/database-service.ts +++ b/query-connector/src/app/database-service.ts @@ -3,8 +3,9 @@ import { Pool, PoolConfig, QueryResultRow } from "pg"; import { Bundle, OperationOutcome, ValueSet as FhirValueSet } from "fhir/r4"; import { Concept, - DEFAULT_ERSD_VERSION, ErsdConceptType, + INTENTIONAL_EMPTY_STRING_FOR_CONCEPT_VERSION, + INTENTIONAL_EMPTY_STRING_FOR_GEM_CODE, ValueSet, ersdToDibbsConceptMap, } from "./constants"; @@ -184,7 +185,7 @@ export async function translateVSACToInternalValueSet( ersdConceptType: ErsdConceptType, ) { const id = fhirValueset.id; - const version = DEFAULT_ERSD_VERSION; + const version = fhirValueset.version; const name = fhirValueset.title; const author = fhirValueset.publisher; @@ -207,3 +208,141 @@ export async function translateVSACToInternalValueSet( concepts: concepts, } as ValueSet; } + +/** + * Function call to insert a new ValueSet into the database. + * @param vs - a ValueSet in of the shape of our internal data model to insert + * @returns success / failure information, as well as errors as appropriate + */ +export async function insertValueSet(vs: ValueSet) { + let errorArray: string[] = []; + + const insertValueSetPromise = generateValueSetSqlPromise(vs); + try { + await insertValueSetPromise; + } catch (e) { + console.error( + `ValueSet insertion for ${vs.valueSetId}_${vs.valueSetVersion} failed`, + ); + console.error(e); + errorArray.push("Error occured in valuset insertion"); + } + + const insertConceptsPromiseArray = generateConceptSqlPromises(vs); + const conceptInsertResults = await Promise.allSettled( + insertConceptsPromiseArray, + ); + + const allConceptInsertsSucceed = conceptInsertResults.every( + (r) => r.status === "fulfilled", + ); + + if (!allConceptInsertsSucceed) { + logRejectedPromiseReasons(conceptInsertResults, "Concept insertion failed"); + errorArray.push("Error occured in concept insertion"); + } + + const joinInsertsPromiseArray = generateValuesetConceptJoinSqlPromises(vs); + const joinInsertResults = await Promise.allSettled(joinInsertsPromiseArray); + + const allJoinInsertsSucceed = joinInsertResults.every( + (r) => r.status === "fulfilled", + ); + + if (!allJoinInsertsSucceed) { + logRejectedPromiseReasons( + joinInsertResults, + "ValueSet <> concept join insert failed", + ); + errorArray.push("Error occured in ValueSet <> concept join seeding"); + } + + if (errorArray.length === 0) return { success: true }; + return { success: false, error: errorArray.join(",") }; +} + +/** + * Helper function to generate the SQL needed for valueset insertion. + * @param vs - The ValueSet in of the shape of our internal data model to insert + * @returns The SQL statement for insertion + */ +function generateValueSetSqlPromise(vs: ValueSet) { + const valueSetOid = vs.valueSetId; + + const valueSetUniqueId = `${valueSetOid}_${vs.valueSetVersion}`; + const insertValueSetSql = + "INSERT INTO valuesets VALUES($1,$2,$3,$4,$5,$6) RETURNING id;"; + const valuesArray = [ + valueSetUniqueId, + valueSetOid, + vs.valueSetVersion, + vs.valueSetName, + vs.author, + vs.ersdConceptType, + ]; + + return dbClient.query(insertValueSetSql, valuesArray); +} + +/** + * Helper function to generate the SQL needed for concept / valueset join insertion + * needed during valueset creation. + * @param vs - The ValueSet in of the shape of our internal data model to insert + * @returns The SQL statement array for all concepts for insertion + */ +function generateConceptSqlPromises(vs: ValueSet) { + const insertConceptsSqlArray = vs.concepts.map((concept) => { + const systemPrefix = stripProtocolAndTLDFromSystemUrl(vs.system); + const conceptUniqueId = `${systemPrefix}_${concept.code}`; + const insertConceptSql = `INSERT INTO concepts VALUES($1,$2,$3,$4,$5,$6) RETURNING id;`; + const conceptInsertPromise = dbClient.query(insertConceptSql, [ + conceptUniqueId, + concept.code, + vs.system, + concept.display, + // see notes in constants file for the intentional empty strings + INTENTIONAL_EMPTY_STRING_FOR_GEM_CODE, + INTENTIONAL_EMPTY_STRING_FOR_CONCEPT_VERSION, + ]); + + return conceptInsertPromise; + }); + + return insertConceptsSqlArray; +} + +function generateValuesetConceptJoinSqlPromises(vs: ValueSet) { + const valueSetUniqueId = `${vs.valueSetId}_${vs.valueSetVersion}`; + const insertConceptsSqlArray = vs.concepts.map((concept) => { + const systemPrefix = stripProtocolAndTLDFromSystemUrl(vs.system); + const conceptUniqueId = `${systemPrefix}_${concept.code}`; + const insertJoinSql = `INSERT INTO valueset_to_concept VALUES($1,$2, $3) RETURNING valueset_id, concept_id;`; + const conceptInsertPromise = dbClient.query(insertJoinSql, [ + `${valueSetUniqueId}_${conceptUniqueId}`, + valueSetUniqueId, + conceptUniqueId, + ]); + + return conceptInsertPromise; + }); + + return insertConceptsSqlArray; +} + +function stripProtocolAndTLDFromSystemUrl(systemURL: string) { + const match = systemURL.match(/https?:\/\/([^\.]+)/); + return match ? match[1] : systemURL; +} + +function logRejectedPromiseReasons( + resultsArray: PromiseSettledResult[], + errorMessageString: string, +) { + return resultsArray + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => { + console.error(errorMessageString); + console.error(r.reason); + return r.reason; + }); +} diff --git a/query-connector/src/app/tests/integration/translation.test.ts b/query-connector/src/app/tests/integration/translation.test.ts index 68cce1169..b619a9534 100644 --- a/query-connector/src/app/tests/integration/translation.test.ts +++ b/query-connector/src/app/tests/integration/translation.test.ts @@ -1,11 +1,11 @@ -import { DEFAULT_ERSD_VERSION, ValueSet } from "@/app/constants"; +import { ValueSet } from "@/app/constants"; import ExampleVsacValueSet from "../assets/VSACValueSet.json"; import { translateVSACToInternalValueSet } from "../../database-service"; import { ValueSet as FhirValueSet } from "fhir/r4"; const EXPECTED_INTERNAL_VALUESET: ValueSet = { valueSetId: ExampleVsacValueSet.id, - valueSetVersion: DEFAULT_ERSD_VERSION, + valueSetVersion: ExampleVsacValueSet.version, valueSetName: ExampleVsacValueSet.title, author: ExampleVsacValueSet.publisher, system: ExampleVsacValueSet.compose.include[0].system,