diff --git a/containers/ecr-viewer/docker-compose.yml b/containers/ecr-viewer/docker-compose.yml index 1551b2473b..ca23842093 100644 --- a/containers/ecr-viewer/docker-compose.yml +++ b/containers/ecr-viewer/docker-compose.yml @@ -37,7 +37,6 @@ services: - ./sql/core.sql:/docker-entrypoint-initdb.d/core.sql - ./seed-scripts/sql/.pgpass/:/usr/local/lib/.pgpass - db:/var/lib/postgresql/data - - ./sql/extended.sql:/var/opt/mssql/scripts/extended.sql environment: - POSTGRES_USER=postgres - PGUSER=postgres @@ -55,11 +54,11 @@ services: image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD=Password1! + - MSSQL_SA_PASSWORD=${SQL_SERVER_PASSWORD} ports: - "1433:1433" volumes: - - ./seed-scripts/sql/entrypoint.sh:/var/opt/mssql/scripts/entrypoint.sh + - ./sql/entrypoint.sh:/var/opt/mssql/scripts/entrypoint.sh - ./sql/extended.sql:/var/opt/mssql/scripts/extended.sql entrypoint: /bin/bash -c "/var/opt/mssql/scripts/entrypoint.sh" healthcheck: diff --git a/containers/ecr-viewer/jest.config.js b/containers/ecr-viewer/jest.config.js index ab91022213..e5b1b5f487 100644 --- a/containers/ecr-viewer/jest.config.js +++ b/containers/ecr-viewer/jest.config.js @@ -9,6 +9,10 @@ const createJestConfig = nextJest({ const customJestConfig = { setupFilesAfterEnv: ["/jest.setup.ts"], testEnvironment: "jest-environment-jsdom", + moduleNameMapper: { + "^mssql$": "/src/app/tests/__mocks__/mssql.js", + "^tedious$": "/src/app/tests/__mocks__/mssql.js", + }, }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/containers/ecr-viewer/seed-scripts/sql/entrypoint.sh b/containers/ecr-viewer/sql/entrypoint.sh similarity index 100% rename from containers/ecr-viewer/seed-scripts/sql/entrypoint.sh rename to containers/ecr-viewer/sql/entrypoint.sh diff --git a/containers/ecr-viewer/sql/extended.sql b/containers/ecr-viewer/sql/extended.sql index dd606bd300..4189f93478 100644 --- a/containers/ecr-viewer/sql/extended.sql +++ b/containers/ecr-viewer/sql/extended.sql @@ -30,19 +30,17 @@ CREATE TABLE ECR_DATA rr_id VARCHAR(255), processing_status VARCHAR(255), eicr_version_number VARCHAR(50), - authoring_date DATE, - authoring_time TIME, + authoring_date DATETIME, authoring_provider VARCHAR(255), provider_id VARCHAR(255), facility_id VARCHAR(255), facility_name VARCHAR(255), encounter_type VARCHAR(255), - encounter_start_date DATE, - encounter_start_time TIME, - encounter_end_date DATE, - encounter_end_time TIME, + encounter_start_date DATETIME, + encounter_end_date DATETIME, reason_for_visit VARCHAR(MAX), - active_problems VARCHAR(MAX) + active_problems VARCHAR(MAX), + date_created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(), ); CREATE TABLE ecr_rr_conditions diff --git a/containers/ecr-viewer/src/app/api/conditions/service.ts b/containers/ecr-viewer/src/app/api/conditions/service.ts index d1f10707af..0aaa2311df 100644 --- a/containers/ecr-viewer/src/app/api/conditions/service.ts +++ b/containers/ecr-viewer/src/app/api/conditions/service.ts @@ -1,8 +1,9 @@ +import { NextResponse } from "next/server"; import pgPromise from "pg-promise"; import { database } from "../services/postgres_db"; -import { NextResponse } from "next/server"; -import sql from "mssql"; import { get_pool } from "../services/sqlserver_db"; +import sql from "mssql"; + /** * Retrieves all unique conditions from the ecr_rr_conditions table in the PostgreSQL database. * @returns A promise resolving to a NextResponse object. diff --git a/containers/ecr-viewer/src/app/api/save-fhir-data/save-fhir-data-service.ts b/containers/ecr-viewer/src/app/api/save-fhir-data/save-fhir-data-service.ts index 374af5e69d..9d1f8d6655 100644 --- a/containers/ecr-viewer/src/app/api/save-fhir-data/save-fhir-data-service.ts +++ b/containers/ecr-viewer/src/app/api/save-fhir-data/save-fhir-data-service.ts @@ -196,7 +196,7 @@ export const saveMetadataToSqlServer = async ( .input("eicr_set_id", sql.VarChar(255), metadata.eicr_set_id) .input("fhir_reference_link", sql.VarChar(255), null) // Not implemented .input("last_name", sql.VarChar(255), metadata.last_name) - .input("first_name", sql.VarChar(255), metadata.last_name) + .input("first_name", sql.VarChar(255), metadata.first_name) .input("birth_date", sql.Date, metadata.birth_date) .input("gender", sql.VarChar(50), metadata.gender) .input("birth_sex", sql.VarChar(50), metadata.birth_sex) @@ -254,21 +254,18 @@ export const saveMetadataToSqlServer = async ( sql.VarChar(50), metadata.eicr_version_number, ) - .input("authoring_date", sql.Date, metadata.authoring_datetime) - .input("authoring_time", sql.Time(7), metadata.authoring_datetime) + .input("authoring_date", sql.DateTime, metadata.authoring_datetime) .input("authoring_provider", sql.VarChar(255), metadata.provider_id) .input("provider_id", sql.VarChar(255), metadata.provider_id) .input("facility_id", sql.VarChar(255), metadata.facility_id_number) .input("facility_name", sql.VarChar(255), metadata.facility_name) .input("encounter_type", sql.VarChar(255), metadata.encounter_type) - .input("encounter_start_date", sql.Date, metadata.encounter_start_date) .input( - "encounter_start_time", - sql.Time(7), + "encounter_start_date", + sql.DateTime, metadata.encounter_start_date, ) - .input("encounter_end_date", sql.Date, metadata.encounter_end_date) - .input("encounter_end_time", sql.Time(7), metadata.encounter_end_date) + .input("encounter_end_date", sql.DateTime, metadata.encounter_end_date) .input( "reason_for_visit", sql.VarChar(sql.MAX), @@ -280,7 +277,7 @@ export const saveMetadataToSqlServer = async ( metadata.active_problems, ) .query( - "INSERT INTO dbo.ECR_DATA VALUES (@eICR_ID, @eicr_set_id, @fhir_reference_link, @last_name, @first_name, @birth_date, @gender, @birth_sex, @gender_identity, @race, @ethnicity, @street_address1, @street_address2, @state, @zip_code, @latitude, @longitude, @homelessness_status, @disabilities, @tribal_affiliation, @tribal_enrollment_status, @current_job_title, @current_job_industry, @usual_occupation, @usual_industry, @preferred_language, @pregnancy_status, @rr_id, @processing_status, @eicr_version_number, @authoring_date, @authoring_time, @authoring_provider, @provider_id, @facility_id, @facility_name, @encounter_type, @encounter_start_date, @encounter_start_time, @encounter_end_date, @encounter_end_time, @reason_for_visit, @active_problems)", + "INSERT INTO dbo.ECR_DATA (eICR_ID, set_id, fhir_reference_link, last_name, first_name, birth_date, gender, birth_sex, gender_identity, race, ethnicity, street_address_1, street_address_2, state, zip_code, latitude, longitude, homelessness_status, disabilities, tribal_affiliation, tribal_enrollment_status, current_job_title, current_job_industry, usual_occupation, usual_industry, preferred_language, pregnancy_status, rr_id, processing_status, eicr_version_number, authoring_date, authoring_provider, provider_id, facility_id, facility_name, encounter_type, encounter_start_date, encounter_end_date, reason_for_visit, active_problems) VALUES (@eICR_ID, @eicr_set_id, @fhir_reference_link, @last_name, @first_name, @birth_date, @gender, @birth_sex, @gender_identity, @race, @ethnicity, @street_address1, @street_address2, @state, @zip_code, @latitude, @longitude, @homelessness_status, @disabilities, @tribal_affiliation, @tribal_enrollment_status, @current_job_title, @current_job_industry, @usual_occupation, @usual_industry, @preferred_language, @pregnancy_status, @rr_id, @processing_status, @eicr_version_number, @authoring_date, @authoring_provider, @provider_id, @facility_id, @facility_name, @encounter_type, @encounter_start_date, @encounter_end_date, @reason_for_visit, @active_problems)", ); if (metadata.labs) { @@ -344,7 +341,7 @@ export const saveMetadataToSqlServer = async ( .input("specimen_type", sql.VarChar(255), lab.specimen_type) .input( "specimen_collection_date", - sql.Date, + sql.DateTime, lab.specimen_collection_date, ) .input("performing_lab", sql.VarChar(255), lab.performing_lab) diff --git a/containers/ecr-viewer/src/app/api/services/listEcrDataService.ts b/containers/ecr-viewer/src/app/api/services/listEcrDataService.ts deleted file mode 100644 index 20e5e1e9a9..0000000000 --- a/containers/ecr-viewer/src/app/api/services/listEcrDataService.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { database } from "@/app/api/services/postgres_db"; -import { - convertUTCToLocalString, - formatDate, - formatDateTime, -} from "@/app/services/formatService"; -import pgPromise from "pg-promise"; - -export type EcrMetadataModel = { - eicr_id: string; - data_source: "DB" | "S3"; - data_link: string; - patient_name_first: string; - patient_name_last: string; - patient_birth_date: Date; - conditions: string[]; - rule_summaries: string[]; - report_date: Date; - date_created: Date; -}; - -export type EcrDisplay = { - ecrId: string; - patient_first_name: string; - patient_last_name: string; - patient_date_of_birth: string | undefined; - reportable_conditions: string[]; - rule_summaries: string[]; - patient_report_date: string; - date_created: string; -}; - -/** - * @param startIndex - The index of the first item to fetch - * @param itemsPerPage - The number of items to fetch - * @param sortColumn - The column to sort by - * @param sortDirection - The direction to sort by - * @param searchTerm - The search term to use - * @param filterConditions - The condition(s) to filter on - * @returns A promise resolving to a list of eCR metadata - */ -export async function listEcrData( - startIndex: number, - itemsPerPage: number, - sortColumn: string, - sortDirection: string, - searchTerm?: string, - filterConditions?: string[], -): Promise { - const list = await database.manyOrNone( - "SELECT ed.eICR_ID, ed.patient_name_first, ed.patient_name_last, ed.patient_birth_date, ed.date_created, ed.report_date, ed.report_date, ARRAY_AGG(DISTINCT erc.condition) AS conditions, ARRAY_AGG(DISTINCT ers.rule_summary) AS rule_summaries FROM ecr_data ed LEFT JOIN ecr_rr_conditions erc ON ed.eICR_ID = erc.eICR_ID LEFT JOIN ecr_rr_rule_summaries ers ON erc.uuid = ers.ecr_rr_conditions_id WHERE $[whereClause] GROUP BY ed.eICR_ID, ed.patient_name_first, ed.patient_name_last, ed.patient_birth_date, ed.date_created, ed.report_date $[sortStatement] OFFSET $[startIndex] ROWS FETCH NEXT $[itemsPerPage] ROWS ONLY", - { - whereClause: generateWhereStatement(searchTerm, filterConditions), - startIndex, - itemsPerPage, - sortStatement: generateSortStatement(sortColumn, sortDirection), - }, - ); - - return processMetadata(list); -} - -/** - * Processes a list of eCR data retrieved from Postgres. - * @param responseBody - The response body containing eCR data from Postgres. - * @returns - The processed list of eCR IDs and dates. - */ -export const processMetadata = ( - responseBody: EcrMetadataModel[], -): EcrDisplay[] => { - return responseBody.map((object) => { - return { - ecrId: object.eicr_id || "", - patient_first_name: object.patient_name_first || "", - patient_last_name: object.patient_name_last || "", - patient_date_of_birth: object.patient_birth_date - ? formatDate(new Date(object.patient_birth_date!).toISOString()) - : "", - reportable_conditions: object.conditions || [], - rule_summaries: object.rule_summaries || [], - date_created: object.date_created - ? convertUTCToLocalString( - formatDateTime(new Date(object.date_created!).toISOString()), - ) - : "", - patient_report_date: object.report_date - ? convertUTCToLocalString( - formatDateTime(new Date(object.report_date!).toISOString()), - ) - : "", - }; - }); -}; - -/** - * Retrieves the total number of eCRs stored in the ecr_data table. - * @param searchTerm - The search term used to filter the count query - * @param filterConditions - The array of reportable conditions used to filter the count query - * @returns A promise resolving to the total number of eCRs. - */ -export const getTotalEcrCount = async ( - searchTerm?: string, - filterConditions?: string[], -): Promise => { - let number = await database.one( - "SELECT count(DISTINCT ed.eICR_ID) FROM ecr_data as ed LEFT JOIN ecr_rr_conditions erc on ed.eICR_ID = erc.eICR_ID WHERE $[whereClause]", - { whereClause: generateWhereStatement(searchTerm, filterConditions) }, - ); - return number.count; -}; - -/** - * A custom type format for where statement - * @param searchTerm - Optional search term used to filter - * @param filterConditions - Optional array of reportable conditions used to filter - * @returns custom type format object for use by pg-promise - */ -export const generateWhereStatement = ( - searchTerm?: string, - filterConditions?: string[], -) => ({ - rawType: true, - toPostgres: () => { - const statementSearch = generateSearchStatement(searchTerm).toPostgres(); - const statementConditions = - generateFilterConditionsStatement(filterConditions).toPostgres(); - - return `(${statementSearch}) AND (${statementConditions})`; - }, -}); - -/** - * A custom type format for search statement - * @param searchTerm - Optional search term used to filter - * @returns custom type format object for use by pg-promise - */ -export const generateSearchStatement = (searchTerm?: string) => ({ - rawType: true, - toPostgres: () => { - const searchFields = ["ed.patient_name_first", "ed.patient_name_last"]; - return searchFields - .map((field) => { - if (!searchTerm) { - return pgPromise.as.format("NULL IS NULL"); - } - return pgPromise.as.format("$[field:raw] ILIKE $[searchTerm]", { - searchTerm: `%${searchTerm}%`, - field, - }); - }) - .join(" OR "); - }, -}); - -/** - * A custom type format for statement filtering conditions - * @param filterConditions - Optional array of reportable conditions used to filter - * @returns custom type format object for use by pg-promise - */ -export const generateFilterConditionsStatement = ( - filterConditions?: string[], -) => ({ - rawType: true, - toPostgres: () => { - if (!filterConditions) { - return pgPromise.as.format("NULL IS NULL"); - } - - const whereStatement = filterConditions - .map((condition) => { - return pgPromise.as.format("erc_sub.condition ILIKE $[condition]", { - condition: `%${condition}%`, - }); - }) - .join(" OR "); - const subQuery = `SELECT DISTINCT ed_sub.eICR_ID FROM ecr_data ed_sub LEFT JOIN ecr_rr_conditions erc_sub ON ed_sub.eICR_ID = erc_sub.eICR_ID WHERE erc_sub.condition IS NOT NULL AND (${whereStatement})`; - return `ed.eICR_ID IN (${subQuery})`; - }, -}); - -/** - * A custom type format for sort statement - * @param columnName - The column to sort by - * @param direction - The direction to sort by - * @returns custom type format object for use by pg-promise - */ -export const generateSortStatement = ( - columnName: string, - direction: string, -) => ({ - rawType: true, - toPostgres: () => { - // Valid columns and directions - const validColumns = ["patient", "date_created", "report_date"]; - const validDirections = ["ASC", "DESC"]; - - // Validation check - if (!validColumns.includes(columnName)) { - columnName = "date_created"; - } - if (!validDirections.includes(direction)) { - direction = "DESC"; - } - - if (columnName === "patient") { - return pgPromise.as.format( - `ORDER BY ed.patient_name_last ${direction}, ed.patient_name_first ${direction}`, - { direction }, - ); - } - - // Default case for other columns - return pgPromise.as.format(`ORDER BY $[columnName:raw] ${direction}`, { - columnName, - }); - }, -}); diff --git a/containers/ecr-viewer/src/app/api/tests/service.test.ts b/containers/ecr-viewer/src/app/api/tests/conditions.service.test.ts similarity index 95% rename from containers/ecr-viewer/src/app/api/tests/service.test.ts rename to containers/ecr-viewer/src/app/api/tests/conditions.service.test.ts index ff47937d6b..e04b7a0049 100644 --- a/containers/ecr-viewer/src/app/api/tests/service.test.ts +++ b/containers/ecr-viewer/src/app/api/tests/conditions.service.test.ts @@ -5,17 +5,12 @@ import { import { database } from "@/app/api/services/postgres_db"; import { NextResponse } from "next/server"; -jest.mock("mssql", () => ({})); // Mock the mssql module - -jest.mock("mssql", () => { - const mssql = { - connect: jest.fn(), - Transaction: jest.fn(), - Request: jest.fn(), - // You can add other mocks if needed - }; - return mssql; -}); +// Mock dependencies +jest.mock("mssql", () => ({ + connect: jest.fn(), + Transaction: jest.fn(), + Request: jest.fn(), +})); jest.mock("../services/postgres_db", () => ({ database: { diff --git a/containers/ecr-viewer/src/app/components/EcrTable.tsx b/containers/ecr-viewer/src/app/components/EcrTable.tsx index d0d5a174cf..7e4e5c3d9b 100644 --- a/containers/ecr-viewer/src/app/components/EcrTable.tsx +++ b/containers/ecr-viewer/src/app/components/EcrTable.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { listEcrData } from "@/app/api/services/listEcrDataService"; +import { listEcrData } from "@/app/services/listEcrDataService"; import { EcrTableClient } from "@/app/components/EcrTableClient"; diff --git a/containers/ecr-viewer/src/app/components/EcrTableClient.tsx b/containers/ecr-viewer/src/app/components/EcrTableClient.tsx index 478738c523..f2348af5f7 100644 --- a/containers/ecr-viewer/src/app/components/EcrTableClient.tsx +++ b/containers/ecr-viewer/src/app/components/EcrTableClient.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { Table } from "@trussworks/react-uswds"; import { SortButton } from "@/app/components/SortButton"; -import { EcrDisplay } from "@/app/api/services/listEcrDataService"; +import { EcrDisplay } from "@/app/services/listEcrDataService"; import { toSentenceCase } from "@/app/services/formatService"; import { usePathname, useSearchParams, useRouter } from "next/navigation"; diff --git a/containers/ecr-viewer/src/app/page.tsx b/containers/ecr-viewer/src/app/page.tsx index 557a6dbd42..700f9c156e 100644 --- a/containers/ecr-viewer/src/app/page.tsx +++ b/containers/ecr-viewer/src/app/page.tsx @@ -1,6 +1,6 @@ import React from "react"; import Header from "./Header"; -import { getTotalEcrCount } from "@/app/api/services/listEcrDataService"; +import { getTotalEcrCount } from "@/app/services/listEcrDataService"; import EcrPaginationWrapper from "@/app/components/EcrPaginationWrapper"; import EcrTable from "@/app/components/EcrTable"; import LibrarySearch from "./components/LibrarySearch"; diff --git a/containers/ecr-viewer/src/app/services/listEcrDataService.ts b/containers/ecr-viewer/src/app/services/listEcrDataService.ts new file mode 100644 index 0000000000..d1b3bc1335 --- /dev/null +++ b/containers/ecr-viewer/src/app/services/listEcrDataService.ts @@ -0,0 +1,446 @@ +import { database } from "@/app/api/services/postgres_db"; +import { + convertUTCToLocalString, + formatDate, + formatDateTime, +} from "@/app/services/formatService"; +import pgPromise from "pg-promise"; +import { get_pool } from "../api/services/sqlserver_db"; + +export interface CoreMetadataModel { + eicr_id: string; + data_source: "DB" | "S3"; + data_link: string; + patient_name_first: string; + patient_name_last: string; + patient_birth_date: Date; + conditions: string[]; + rule_summaries: string[]; + report_date: Date; + date_created: Date; +} + +export interface ExtendedMetadataModel { + eICR_ID: string; + data_source: "DB" | "S3"; + data_link: string; + first_name: string; + last_name: string; + birth_date: Date; + conditions: string; + rule_summaries: string; + encounter_start_date: Date; + date_created: Date; +} + +export interface EcrDisplay { + ecrId: string; + patient_first_name: string; + patient_last_name: string; + patient_date_of_birth: string | undefined; + reportable_conditions: string[]; + rule_summaries: string[]; + patient_report_date: string; + date_created: string; +} + +/** + * @param startIndex - The index of the first item to fetch + * @param itemsPerPage - The number of items to fetch + * @param sortColumn - The column to sort by + * @param sortDirection - The direction to sort by + * @param searchTerm - The search term to use + * @param filterConditions - The condition(s) to filter on + * @returns A promise resolving to a list of eCR metadata + */ +export async function listEcrData( + startIndex: number, + itemsPerPage: number, + sortColumn: string, + sortDirection: string, + searchTerm?: string, + filterConditions?: string[], +): Promise { + const DATABASE_TYPE = process.env.METADATA_DATABASE_TYPE; + + switch (DATABASE_TYPE) { + case "postgres": + return listEcrDataPostgres( + startIndex, + itemsPerPage, + sortColumn, + sortDirection, + searchTerm, + filterConditions, + ); + case "sqlserver": + return listEcrDataSqlserver( + startIndex, + itemsPerPage, + sortColumn, + sortDirection, + searchTerm, + filterConditions, + ); + default: + throw new Error("Unsupported database type"); + } +} + +async function listEcrDataPostgres( + startIndex: number, + itemsPerPage: number, + sortColumn: string, + sortDirection: string, + searchTerm?: string, + filterConditions?: string[], +): Promise { + const list = await database.manyOrNone( + "SELECT ed.eICR_ID, ed.patient_name_first, ed.patient_name_last, ed.patient_birth_date, ed.date_created, ed.report_date, ed.report_date, ARRAY_AGG(DISTINCT erc.condition) AS conditions, ARRAY_AGG(DISTINCT ers.rule_summary) AS rule_summaries FROM ecr_data ed LEFT JOIN ecr_rr_conditions erc ON ed.eICR_ID = erc.eICR_ID LEFT JOIN ecr_rr_rule_summaries ers ON erc.uuid = ers.ecr_rr_conditions_id WHERE $[whereClause] GROUP BY ed.eICR_ID, ed.patient_name_first, ed.patient_name_last, ed.patient_birth_date, ed.date_created, ed.report_date $[sortStatement] OFFSET $[startIndex] ROWS FETCH NEXT $[itemsPerPage] ROWS ONLY", + { + whereClause: generateWhereStatementPostgres(searchTerm, filterConditions), + startIndex, + itemsPerPage, + sortStatement: generateSortStatement(sortColumn, sortDirection), + }, + ); + + return processCoreMetadata(list); +} + +async function listEcrDataSqlserver( + startIndex: number, + itemsPerPage: number, + sortColumn: string, + sortDirection: string, + searchTerm?: string, + filterConditions?: string[], +): Promise { + let pool = await get_pool(); + + try { + const conditionsSubQuery = + "SELECT STRING_AGG([condition], ',') FROM (SELECT DISTINCT erc.[condition] FROM ecr_rr_conditions AS erc WHERE erc.eICR_ID = ed.eICR_ID) AS distinct_conditions"; + const ruleSummariesSubQuery = + "SELECT STRING_AGG(rule_summary, ',') FROM (SELECT DISTINCT ers.rule_summary FROM ecr_rr_rule_summaries AS ers LEFT JOIN ecr_rr_conditions as erc ON ers.ecr_rr_conditions_id = erc.uuid WHERE erc.eICR_ID = ed.eICR_ID) AS distinct_rule_summaries"; + const sortStatement = generateSqlServerSortStatement( + sortColumn, + sortDirection, + ); + const whereStatement = generateWhereStatementSqlServer( + searchTerm, + filterConditions, + ); + const query = `SELECT ed.eICR_ID, ed.first_name, ed.last_name, ed.birth_date, ed.encounter_start_date, ed.date_created, (${conditionsSubQuery}) AS conditions, (${ruleSummariesSubQuery}) AS rule_summaries FROM ecr_data ed LEFT JOIN ecr_rr_conditions erc ON ed.eICR_ID = erc.eICR_ID LEFT JOIN ecr_rr_rule_summaries ers ON erc.uuid = ers.ecr_rr_conditions_id WHERE ${whereStatement} GROUP BY ed.eICR_ID, ed.first_name, ed.last_name, ed.birth_date, ed.encounter_start_date, ed.date_created ${sortStatement} OFFSET ${startIndex} ROWS FETCH NEXT ${itemsPerPage} ROWS ONLY`; + const list = await pool.request().query(query); + + return processExtendedMetadata(list.recordset); + } catch (error: any) { + console.error(error); + return Promise.reject(error); + } finally { + pool.close(); + } +} + +/** + * Processes a list of eCR data retrieved from Postgres. + * @param responseBody - The response body containing eCR data from Postgres. + * @returns - The processed list of eCR IDs and dates. + */ +export const processCoreMetadata = ( + responseBody: CoreMetadataModel[], +): EcrDisplay[] => { + return responseBody.map((object) => { + return { + ecrId: object.eicr_id || "", + patient_first_name: object.patient_name_first || "", + patient_last_name: object.patient_name_last || "", + patient_date_of_birth: object.patient_birth_date + ? formatDate(new Date(object.patient_birth_date!).toISOString()) + : "", + reportable_conditions: object.conditions || [], + rule_summaries: object.rule_summaries || [], + date_created: object.date_created + ? convertUTCToLocalString( + formatDateTime(new Date(object.date_created!).toISOString()), + ) + : "", + patient_report_date: object.report_date + ? convertUTCToLocalString( + formatDateTime(new Date(object.report_date!).toISOString()), + ) + : "", + }; + }); +}; + +/** + * Processes a list of eCR data retrieved from Postgres. + * @param responseBody - The response body containing eCR data from Postgres. + * @returns - The processed list of eCR IDs and dates. + */ +const processExtendedMetadata = ( + responseBody: ExtendedMetadataModel[], +): EcrDisplay[] => { + return responseBody.map((object) => { + const result = { + ecrId: object.eICR_ID || "", + patient_first_name: object.first_name || "", + patient_last_name: object.last_name || "", + patient_date_of_birth: object.birth_date + ? formatDate(new Date(object.birth_date!).toISOString()) + : "", + reportable_conditions: object.conditions?.split(",") ?? [], + rule_summaries: object.rule_summaries?.split(",") ?? [], + date_created: object.date_created + ? convertUTCToLocalString( + formatDateTime(new Date(object.date_created!).toISOString()), + ) + : "", + patient_report_date: object.encounter_start_date + ? convertUTCToLocalString( + formatDateTime(object.encounter_start_date.toISOString()), + ) + : "", + }; + + return result; + }); +}; + +/** + * Retrieves the total number of eCRs stored in the ecr_data table. + * @param searchTerm - The search term used to filter the count query + * @param filterConditions - The array of reportable conditions used to filter the count query + * @returns A promise resolving to the total number of eCRs. + */ +export const getTotalEcrCount = async ( + searchTerm?: string, + filterConditions?: string[], +): Promise => { + const DATABASE_TYPE = process.env.METADATA_DATABASE_TYPE; + + switch (DATABASE_TYPE) { + case "postgres": + return getTotalEcrCountPostgres(searchTerm, filterConditions); + case "sqlserver": + return getTotalEcrCountSqlServer(searchTerm, filterConditions); + default: + throw new Error("Unsupported database type"); + } +}; + +const getTotalEcrCountPostgres = async ( + searchTerm?: string, + filterConditions?: string[], +): Promise => { + let number = await database.one( + "SELECT count(DISTINCT ed.eICR_ID) FROM ecr_data as ed LEFT JOIN ecr_rr_conditions erc on ed.eICR_ID = erc.eICR_ID WHERE $[whereClause]", + { + whereClause: generateWhereStatementPostgres(searchTerm, filterConditions), + }, + ); + return number.count; +}; + +const getTotalEcrCountSqlServer = async ( + searchTerm?: string, + filterConditions?: string[], +): Promise => { + let pool = await get_pool(); + + try { + const whereStatement = generateWhereStatementSqlServer( + searchTerm, + filterConditions, + ); + + let query = `SELECT COUNT(DISTINCT ed.eICR_ID) as count FROM ecr_data ed LEFT JOIN ecr_rr_conditions erc ON ed.eICR_ID = erc.eICR_ID WHERE ${whereStatement}`; + + const count = await pool.request().query<{ count: number }>(query); + + return count.recordset[0].count; + } catch (error: any) { + console.error(error); + return Promise.reject(error); + } finally { + pool.close(); + } +}; + +/** + * A custom type format for where statement + * @param searchTerm - Optional search term used to filter + * @param filterConditions - Optional array of reportable conditions used to filter + * @returns custom type format object for use by pg-promise + */ +export const generateWhereStatementPostgres = ( + searchTerm?: string, + filterConditions?: string[], +) => ({ + rawType: true, + toPostgres: () => { + const statementSearch = generateSearchStatement(searchTerm).toPostgres(); + const statementConditions = + generateFilterConditionsStatement(filterConditions).toPostgres(); + + return `(${statementSearch}) AND (${statementConditions})`; + }, +}); + +/** + * Generate where statement for SQL Server + * @param searchTerm - Optional search term used to filter + * @param filterConditions - Optional array of reportable conditions used to filter + * @returns - where statement for SQL Server + */ +const generateWhereStatementSqlServer = ( + searchTerm?: string, + filterConditions?: string[], +) => { + const statementSearch = generateSearchStatementSqlServer(searchTerm); + const statementConditions = + generateFilterConditionsStatementSqlServer(filterConditions); + + return `(${statementSearch}) AND (${statementConditions})`; +}; + +/** + * A custom type format for search statement + * @param searchTerm - Optional search term used to filter + * @returns custom type format object for use by pg-promise + */ +export const generateSearchStatement = (searchTerm?: string) => ({ + rawType: true, + toPostgres: () => { + const searchFields = ["ed.patient_name_first", "ed.patient_name_last"]; + return searchFields + .map((field) => { + if (!searchTerm) { + return pgPromise.as.format("NULL IS NULL"); + } + return pgPromise.as.format("$[field:raw] ILIKE $[searchTerm]", { + searchTerm: `%${searchTerm}%`, + field, + }); + }) + .join(" OR "); + }, +}); + +const generateSearchStatementSqlServer = (searchTerm?: string) => { + const searchFields = ["ed.first_name", "ed.last_name"]; + return searchFields + .map((field) => { + if (!searchTerm) { + return "NULL IS NULL"; + } + return `${field} LIKE '%${searchTerm}%'`; + }) + .join(" OR "); +}; + +/** + * A custom type format for statement filtering conditions + * @param filterConditions - Optional array of reportable conditions used to filter + * @returns custom type format object for use by pg-promise + */ +export const generateFilterConditionsStatement = ( + filterConditions?: string[], +) => ({ + rawType: true, + toPostgres: () => { + if (!filterConditions) { + return pgPromise.as.format("NULL IS NULL"); + } + + const whereStatement = filterConditions + .map((condition) => { + return pgPromise.as.format("erc_sub.condition ILIKE $[condition]", { + condition: `%${condition}%`, + }); + }) + .join(" OR "); + const subQuery = `SELECT DISTINCT ed_sub.eICR_ID FROM ecr_data ed_sub LEFT JOIN ecr_rr_conditions erc_sub ON ed_sub.eICR_ID = erc_sub.eICR_ID WHERE erc_sub.condition IS NOT NULL AND (${whereStatement})`; + return `ed.eICR_ID IN (${subQuery})`; + }, +}); + +const generateFilterConditionsStatementSqlServer = ( + filterConditions?: string[], +) => { + if (!filterConditions) { + return "NULL IS NULL"; + } + + const whereStatement = filterConditions + .map((condition) => { + return `erc_sub.condition LIKE '${condition}'`; + }) + .join(" OR "); + const subQuery = `SELECT DISTINCT ed_sub.eICR_ID FROM ecr_data ed_sub LEFT JOIN ecr_rr_conditions erc_sub ON ed_sub.eICR_ID = erc_sub.eICR_ID WHERE erc_sub.condition IS NOT NULL AND (${whereStatement})`; + return `ed.eICR_ID IN (${subQuery})`; +}; + +/** + * A custom type format for sort statement + * @param columnName - The column to sort by + * @param direction - The direction to sort by + * @returns custom type format object for use by pg-promise + */ +export const generateSortStatement = ( + columnName: string, + direction: string, +) => ({ + rawType: true, + toPostgres: () => { + // Valid columns and directions + const validColumns = ["patient", "date_created", "report_date"]; + const validDirections = ["ASC", "DESC"]; + + // Validation check + if (!validColumns.includes(columnName)) { + columnName = "date_created"; + } + if (!validDirections.includes(direction)) { + direction = "DESC"; + } + + if (columnName === "patient") { + return pgPromise.as.format( + `ORDER BY ed.patient_name_last ${direction}, ed.patient_name_first ${direction}`, + { direction }, + ); + } + + // Default case for other columns + return pgPromise.as.format(`ORDER BY $[columnName:raw] ${direction}`, { + columnName, + }); + }, +}); + +const generateSqlServerSortStatement = ( + columnName: string, + direction: string, +) => { + // Valid columns and directions + const validColumns = ["patient", "date_created", "encounter_start_date"]; + const validDirections = ["ASC", "DESC"]; + + // Validation check + if (!validColumns.includes(columnName)) { + columnName = "date_created"; + } + if (!validDirections.includes(direction)) { + direction = "DESC"; + } + + if (columnName === "patient") { + return `ORDER BY ed.first_name ${direction}, ed.last_name ${direction}`; + } + + // Default case for other columns + return `ORDER BY ed.${columnName} ${direction}`; +}; diff --git a/containers/ecr-viewer/src/app/tests/HomePage.test.tsx b/containers/ecr-viewer/src/app/tests/HomePage.test.tsx index 34b1eeebbb..36118063ca 100644 --- a/containers/ecr-viewer/src/app/tests/HomePage.test.tsx +++ b/containers/ecr-viewer/src/app/tests/HomePage.test.tsx @@ -3,9 +3,9 @@ import HomePage from "@/app/page"; import { getTotalEcrCount, listEcrData, -} from "@/app/api/services/listEcrDataService"; +} from "@/app/services/listEcrDataService"; -jest.mock("../../app/api/services/listEcrDataService"); +jest.mock("../services/listEcrDataService"); jest.mock("../components/EcrPaginationWrapper"); jest.mock("../components/LibrarySearch"); @@ -20,6 +20,7 @@ describe("Home Page", () => { expect(screen.getByText("Page not found")).toBeInTheDocument(); }); it("env invalid value, should not show the homepage", async () => { + // @ts-ignore process.env.NEXT_PUBLIC_NON_INTEGRATED_VIEWER = "foo"; render(await HomePage({ searchParams: {} })); expect(screen.getByText("Page not found")).toBeInTheDocument(); diff --git a/containers/ecr-viewer/src/app/tests/__mocks__/mssql.js b/containers/ecr-viewer/src/app/tests/__mocks__/mssql.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/containers/ecr-viewer/src/app/tests/__mocks__/mssql.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/containers/ecr-viewer/src/app/tests/__mocks__/tedious.js b/containers/ecr-viewer/src/app/tests/__mocks__/tedious.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/containers/ecr-viewer/src/app/tests/__mocks__/tedious.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/containers/ecr-viewer/src/app/tests/components/EcrTable.test.tsx b/containers/ecr-viewer/src/app/tests/components/EcrTable.test.tsx index 57a4391ab4..8205f45e16 100644 --- a/containers/ecr-viewer/src/app/tests/components/EcrTable.test.tsx +++ b/containers/ecr-viewer/src/app/tests/components/EcrTable.test.tsx @@ -1,10 +1,10 @@ import { axe } from "jest-axe"; import { render } from "@testing-library/react"; import EcrTable from "@/app/components/EcrTable"; -import { EcrDisplay, listEcrData } from "@/app/api/services/listEcrDataService"; +import { EcrDisplay, listEcrData } from "@/app/services/listEcrDataService"; import router from "next-router-mock"; -jest.mock("../../api/services/listEcrDataService"); +jest.mock("../../services/listEcrDataService"); jest.mock("next/navigation", () => ({ useRouter: () => router, diff --git a/containers/ecr-viewer/src/app/api/tests/listEcrDataService.test.tsx b/containers/ecr-viewer/src/app/tests/listEcrDataService.test.tsx similarity index 79% rename from containers/ecr-viewer/src/app/api/tests/listEcrDataService.test.tsx rename to containers/ecr-viewer/src/app/tests/listEcrDataService.test.tsx index 36e24e365e..f835dc971a 100644 --- a/containers/ecr-viewer/src/app/api/tests/listEcrDataService.test.tsx +++ b/containers/ecr-viewer/src/app/tests/listEcrDataService.test.tsx @@ -2,26 +2,31 @@ * @jest-environment node */ import { - listEcrData, + CoreMetadataModel, EcrDisplay, - processMetadata, - EcrMetadataModel, - getTotalEcrCount, - generateSearchStatement, generateFilterConditionsStatement, - generateWhereStatement, -} from "@/app/api/services/listEcrDataService"; -import { database } from "../services/postgres_db"; + generateSearchStatement, + generateWhereStatementPostgres, + getTotalEcrCount, + listEcrData, + processCoreMetadata, +} from "../services/listEcrDataService"; import { convertUTCToLocalString, formatDate, formatDateTime, -} from "../../services/formatService"; +} from "../services/formatService"; +import { database } from "../api/services/postgres_db"; +import { get_pool } from "../api/services/sqlserver_db"; + +jest.mock("../api/services/sqlserver_db", () => ({ + get_pool: jest.fn(), +})); describe("listEcrDataService", () => { describe("process Metadata", () => { it("should return an empty array when responseBody is empty", () => { - const result = processMetadata([]); + const result = processCoreMetadata([]); expect(result).toEqual([]); }); @@ -30,7 +35,7 @@ describe("listEcrDataService", () => { const date2 = new Date(); const date3 = new Date(); - const responseBody: EcrMetadataModel[] = [ + const responseBody: CoreMetadataModel[] = [ { eicr_id: "ecr1", date_created: date1, @@ -41,7 +46,6 @@ describe("listEcrDataService", () => { conditions: ["Long"], rule_summaries: ["Longer"], data_source: "DB", - data: "", data_link: "", }, { @@ -54,7 +58,6 @@ describe("listEcrDataService", () => { conditions: ["Stuff"], rule_summaries: ["Other stuff", "Even more stuff"], data_source: "DB", - data: "", data_link: "", }, ]; @@ -92,13 +95,13 @@ describe("listEcrDataService", () => { ]), }, ]; - const result = processMetadata(responseBody); + const result = processCoreMetadata(responseBody); expect(result).toEqual(expected); }); }); - describe("list Ecr data", () => { + describe("list Ecr data with postgres", () => { it("should return empty array when no data is found", async () => { let startIndex = 0; let itemsPerPage = 25; @@ -125,8 +128,8 @@ describe("listEcrDataService", () => { }); it("should return data when found", async () => { - database.manyOrNone = jest.fn(() => - Promise.resolve([ + database.manyOrNone = jest.fn(() => + Promise.resolve([ { eicr_id: "1234", date_created: new Date("2024-06-21T12:00:00Z"), @@ -136,7 +139,6 @@ describe("listEcrDataService", () => { report_date: new Date("06/21/2024 8:00 AM EDT"), conditions: ["super ebola", "double ebola"], rule_summaries: ["watch out for super ebola"], - data: "", data_link: "", data_source: "DB", }, @@ -179,8 +181,8 @@ describe("listEcrDataService", () => { }); it("should get data from the fhir_metadata table", async () => { - database.manyOrNone = jest.fn(() => - Promise.resolve([ + database.manyOrNone = jest.fn(() => + Promise.resolve([ { eicr_id: "1234", date_created: new Date("2024-06-21T12:00:00Z"), @@ -190,7 +192,6 @@ describe("listEcrDataService", () => { report_date: new Date("2024-06-20T04:00:00.000Z"), conditions: ["sick", "tired"], rule_summaries: ["stuff", "disease discovered"], - data: "", data_link: "", data_source: "DB", }, @@ -284,6 +285,102 @@ describe("listEcrDataService", () => { }); }); + describe("listEcrDataService with SQL Server", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env.METADATA_DATABASE_TYPE = "sqlserver"; + }); + afterAll(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env.METADATA_DATABASE_TYPE = "postgres"; + }); + + describe("listEcrData with SQL Server", () => { + it("should return data when DATABASE_TYPE is sqlserver", async () => { + // Arrange + const mockRecordset = [ + { + eICR_ID: "123", + first_name: "John", + last_name: "Doe", + birth_date: new Date("1990-01-01"), + encounter_start_date: new Date("2023-01-01T07:30:00Z"), + date_created: new Date("2023-01-02T07:45:00Z"), + conditions: "Condition1,Condition2", + rule_summaries: "Rule1,Rule2", + }, + ]; + + const mockQuery = jest + .fn() + .mockResolvedValue({ recordset: mockRecordset }); + const mockRequest = { + query: mockQuery, + }; + const mockPool = { + request: jest.fn().mockReturnValue(mockRequest), + close: jest.fn(), + }; + + // Mock get_pool to return mockPool + (get_pool as jest.Mock).mockResolvedValue(mockPool); + + // Act + const result = await listEcrData(0, 10, "date_created", "DESC"); + + // Assert + expect(result).toEqual([ + { + ecrId: "123", + patient_first_name: "John", + patient_last_name: "Doe", + patient_date_of_birth: "01/01/1990", + reportable_conditions: ["Condition1", "Condition2"], + rule_summaries: ["Rule1", "Rule2"], + date_created: "01/02/2023 2:45 AM EST", + patient_report_date: "01/01/2023 2:30 AM EST", + }, + ]); + + expect(get_pool).toHaveBeenCalled(); + expect(mockPool.request).toHaveBeenCalled(); + expect(mockQuery).toHaveBeenCalled(); + expect(mockPool.close).toHaveBeenCalled(); + }); + }); + + describe("getTotalEcrCount with SQL Server", () => { + it("should return count when DATABASE_TYPE is sqlserver", async () => { + // Arrange + const mockRecordset = [{ count: 42 }]; + const mockQuery = jest + .fn() + .mockResolvedValue({ recordset: mockRecordset }); + const mockRequest = { + query: mockQuery, + }; + const mockPool = { + request: jest.fn().mockReturnValue(mockRequest), + close: jest.fn(), + }; + + (get_pool as jest.Mock).mockResolvedValue(mockPool); + + // Act + const count = await getTotalEcrCount(); + + // Assert + expect(count).toEqual(42); + expect(get_pool).toHaveBeenCalled(); + expect(mockPool.request).toHaveBeenCalled(); + expect(mockQuery).toHaveBeenCalled(); + expect(mockPool.close).toHaveBeenCalled(); + }); + }); + }); + describe("get total ecr count", () => { it("should call db to get all ecrs", async () => { database.one<{ count: number }> = jest.fn(() => @@ -374,19 +471,23 @@ describe("listEcrDataService", () => { describe("generate where statement", () => { it("should generate where statement using search and filter conditions statements", () => { expect( - generateWhereStatement("blah", ["Anthrax (disorder)"]).toPostgres(), + generateWhereStatementPostgres("blah", [ + "Anthrax (disorder)", + ]).toPostgres(), ).toEqual( "(ed.patient_name_first ILIKE '%blah%' OR ed.patient_name_last ILIKE '%blah%') AND (ed.eICR_ID IN (SELECT DISTINCT ed_sub.eICR_ID FROM ecr_data ed_sub LEFT JOIN ecr_rr_conditions erc_sub ON ed_sub.eICR_ID = erc_sub.eICR_ID WHERE erc_sub.condition IS NOT NULL AND (erc_sub.condition ILIKE '%Anthrax (disorder)%')))", ); }); it("should generate where statement using search statement (no filter conditions provided", () => { - expect(generateWhereStatement("blah", undefined).toPostgres()).toEqual( + expect( + generateWhereStatementPostgres("blah", undefined).toPostgres(), + ).toEqual( "(ed.patient_name_first ILIKE '%blah%' OR ed.patient_name_last ILIKE '%blah%') AND (NULL IS NULL)", ); }); it("should generate where statement using filter conditions statement (no search provided)", () => { expect( - generateWhereStatement("", ["Anthrax (disorder)"]).toPostgres(), + generateWhereStatementPostgres("", ["Anthrax (disorder)"]).toPostgres(), ).toEqual( "(NULL IS NULL OR NULL IS NULL) AND (ed.eICR_ID IN (SELECT DISTINCT ed_sub.eICR_ID FROM ecr_data ed_sub LEFT JOIN ecr_rr_conditions erc_sub ON ed_sub.eICR_ID = erc_sub.eICR_ID WHERE erc_sub.condition IS NOT NULL AND (erc_sub.condition ILIKE '%Anthrax (disorder)%')))", );