department-of-veterans-affairs/vets-website

View on GitHub
src/applications/mhv-medical-records/util/radiologyUtil.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * This function was based on RadiologyTransformer.parseRadiologyReport from MHV Classic. It pulls
 * fields out of a text report and puts them into an object.
 *
 * @param {String} radiologyReportText the raw text of a radiology report
 * @returns an object containing information from the report
 */
export const parseRadiologyReport = radiologyReportText => {
  const radiologyReportMap = {};
  const lines = radiologyReportText ? radiologyReportText.split(/\r?\n/) : [];
  let i = 0;
  let clinicalHistory = '';
  let reportText = '';
  let impressionText = '';
  let inImpression = false;

  while (i < lines.length) {
    const line = lines[i].trim();

    if (line.startsWith('Exm Date:')) {
      // Extract "Exm Date"
      radiologyReportMap['Exm Date'] = line
        .substring('Exm Date:'.length)
        .trim();
    } else if (line.startsWith('Date Verified:')) {
      // Extract "Date Verified" and validate date pattern
      let dateValue = line.substring('Date Verified:'.length).trim();
      const datePattern = /[A-Z]{3} \d{1,2}, \d{4}/i;
      if (!datePattern.test(dateValue)) {
        dateValue = '[Unknown]';
      }
      radiologyReportMap['Date Verified'] = dateValue;
    } else if (line.startsWith('Reason for Study:')) {
      // Extract "Reason for Study"
      radiologyReportMap['Reason for Study'] = line
        .substring('Reason for Study:'.length)
        .trim();
    } else if (line.startsWith('Req Phys:')) {
      // Extract "Req Phys" and remove "Pat Loc" and subsequent text
      let value = line.substring('Req Phys:'.length).trim();
      const patLocIndex = value.indexOf('Pat Loc');
      if (patLocIndex !== -1) {
        value = value.substring(0, patLocIndex).trim();
      }
      radiologyReportMap['Req Phys'] = value;
    } else if (line.startsWith('Clinical History:')) {
      // Begin collecting "Clinical History"
      clinicalHistory = '';
      i += 1;
      while (i < lines.length) {
        const historyLine = lines[i].trim();
        if (historyLine.startsWith('Report Status:')) {
          // Extract "Report Status" and remove "Date Reported" if present
          let statusValue = historyLine
            .substring('Report Status:'.length)
            .trim();
          const dateReportedIndex = statusValue.indexOf('Date Reported');
          if (dateReportedIndex !== -1) {
            statusValue = statusValue.substring(0, dateReportedIndex).trim();
          }
          radiologyReportMap['Report Status'] = statusValue;
          break; // Exit the Clinical History collection
        } else if (historyLine !== '') {
          clinicalHistory += `${historyLine}\n`;
        }
        i += 1;
      }
      radiologyReportMap['Clinical History'] = clinicalHistory.trim();
    } else if (line.startsWith('Report:')) {
      // Begin collecting "Report" and "Impression"
      reportText = '';
      impressionText = '';
      inImpression = false;
      i += 1;
      while (i < lines.length) {
        const reportLine = lines[i].trim();
        if (reportLine.startsWith('Impression:')) {
          // Switch to collecting "Impression"
          inImpression = true;
        } else if (reportLine.startsWith('Primary Interpreting Staff:')) {
          // End of "Impression" section
          break; // Exit the Report collection
        } else if (inImpression) {
          if (reportLine !== '') {
            impressionText += `${reportLine}\n`;
          }
        } else if (reportLine !== '') {
          reportText += `${reportLine}\n`;
        }
        i += 1;
      }
      radiologyReportMap.Report = reportText.trim();
      radiologyReportMap.Impression = impressionText.trim();
    }
    i += 1;
  }

  return radiologyReportMap;
};

/**
 * Compare two dates and return true if they are both valid and are equal to the minute.
 * The dates can have the formats: "1712264604902", "2004-01-06T19:27:00Z"
 *
 * @param {String} var1 first date string
 * @param {String} var2 second date string
 * @returns true if the dates are equal to the minute, otherwise false
 */
export const areDatesEqualToMinute = (var1, var2) => {
  const parseDate = input => {
    let date;
    if (/^\d+$/.test(input)) {
      // Input is a numeric string, parse it as a number (timestamp)
      date = new Date(Number(input));
    } else {
      // Input is likely an ISO date string
      date = new Date(input);
    }
    return date;
  };

  const date1 = parseDate(var1);
  const date2 = parseDate(var2);

  if (Number.isNaN(date1.getTime()) || Number.isNaN(date2.getTime())) {
    return false;
  }

  return (
    date1.getUTCFullYear() === date2.getUTCFullYear() &&
    date1.getUTCMonth() === date2.getUTCMonth() &&
    date1.getUTCDate() === date2.getUTCDate() &&
    date1.getUTCHours() === date2.getUTCHours() &&
    date1.getUTCMinutes() === date2.getUTCMinutes()
  );
};

/**
 * The reports match if the PHR eventDate and the CVIX performedDatePrecise
 * values match to the minute.
 *
 * @param {*} phrResponse a single PHR record
 * @param {*} cvixResponse a single CVIX record
 */
export const radiologyReportsMatch = (phrResponse, cvixResponse) => {
  return areDatesEqualToMinute(
    phrResponse?.eventDate,
    cvixResponse?.performedDatePrecise,
  );
};

/**
 * @param {*} phrResponse a single PHR record from a response
 * @param {*} cvixResponseList a response containing a list of CVIX records
 * @returns the matching CVIX record from the response if one exists, otherwise null
 */
export const findMatchingCvixReport = (phrResponse, cvixResponseList) => {
  if (phrResponse && Array.isArray(cvixResponseList)) {
    for (const cvixResponse of cvixResponseList) {
      if (radiologyReportsMatch(phrResponse, cvixResponse)) {
        return cvixResponse;
      }
    }
  }
  return null;
};

/**
 * Generate a hash for fingerprinting an object. This hash does not need to be cryptographically
 * strong. We used to use crypto.subtle.digest to generate the hash, but that library does not
 * work on non-https connections, limiting testing.
 *
 * @param {string} data
 * @returns a non-cryptographic hash
 */
const generateHash = data => {
  let hash = 0;
  for (let i = 0; i < data.length; i++) {
    const char = data.charCodeAt(i);
    hash = (hash * 31 + char) % 2 ** 32;
  }
  return hash.toString(16).padStart(8, '0'); // Convert to hexadecimal, pad to 8 chars
};

export const radiologyRecordHash = async record => {
  const { procedureName, radiologist, stationNumber } = record;
  const date = record.eventDate || record.performedDatePrecise;
  const dataString = `${procedureName}|${radiologist}|${stationNumber}|${date}`;
  return (await generateHash(dataString)).substring(0, 8);
};

/**
 * @param {*} id the ID (PHR if available, otherwise CVIX) of the desired study, with hash (e.g. (r12345-abcde")
 * @param {*} phrResponse a list of PHR radiology responses from the API
 * @param {*} cvixResponse a list of CVIX radiology responses from the API
 * @returns an object containing the PHR study and CVIX study that match the provided ID
 */
export const findMatchingPhrAndCvixStudies = async (
  id,
  phrResponse,
  cvixResponse,
) => {
  const [numericIdStr, hashId] = id.substring(1).split('-');
  const numericId = +numericIdStr;

  // Helper function to find a record first by numeric ID, then by hash
  const findRecordByIdOrHash = async (records, findNumericId, findHashId) => {
    if (!records) return null;
    const foundRecord = records.find(r => +r.id === findNumericId);
    if (foundRecord) return foundRecord;

    // If not found by ID, compute hashes and find by hash
    const recordsWithHash = await Promise.all(
      records.map(async r => ({
        ...r,
        hash: await radiologyRecordHash(r),
      })),
    );
    return recordsWithHash.find(r => r.hash === findHashId) || null;
  };

  const phrDetails = await findRecordByIdOrHash(phrResponse, numericId, hashId);

  let cvixDetails;
  if (phrDetails) {
    cvixDetails = findMatchingCvixReport(phrDetails, cvixResponse);
  } else {
    cvixDetails = await findRecordByIdOrHash(cvixResponse, numericId, hashId);
  }

  return { phrDetails, cvixDetails };
};