department-of-veterans-affairs/vets-website

View on GitHub
src/platform/pdf/templates/self_entered_info.js

Summary

Maintainability
F
1 wk
Test Coverage
/**
 * Blue Button PDF template.
 *
 * NB: The order in which items are added to the document is important,
 * and thus PDFKit requires performing operations synchronously.
 */
/* eslint-disable no-await-in-loop */

import { capitalize } from 'lodash';
import { MissingFieldsException } from '../utils/exceptions/MissingFieldsException';
import {
  createAccessibleDoc,
  addHorizontalRule,
  createDetailItem,
  createHeading,
  createSubHeading,
  getTestResultBlockHeight,
  registerVaGovFonts,
  generateFinalHeaderContent,
  generateFooterContent,
  generateInitialHeaderContent,
  createRichTextDetailItem,
} from './utils';

const config = {
  margins: {
    top: 40,
    bottom: 40,
    left: 17,
    right: 16,
  },
  headings: {
    H1: {
      font: 'Bitter-Bold',
      size: 30,
    },
    H2: {
      font: 'Bitter-Bold',
      size: 24,
    },
    H3: {
      font: 'Bitter-Bold',
      size: 16,
    },
    H4: {
      font: 'Bitter-Bold',
      size: 14,
    },
    H5: {
      font: 'Bitter-Bold',
      size: 12,
    },
  },
  subHeading: {
    font: 'Bitter-Regular',
    size: 12,
  },
  text: {
    boldFont: 'SourceSansPro-Bold',
    monospaceFont: 'RobotoMono-Regular',
    font: 'SourceSansPro-Regular',
    size: 12,
  },
  headerGap: 2.5,
};

const selfEnteredTypes = {
  ACTIVITY_JOURNAL: 'activity journal',
  ALLERGIES: 'allergies',
  DEMOGRAPHICS: 'demographics',
  FAMILY_HISTORY: 'family health history',
  FOOD_JOURNAL: 'food journal',
  // GOALS: 'goals', // dont have this
  HEALTH_PROVIDERS: 'healthcare providers',
  HEALTH_INSURANCE: 'health insurance',
  TEST_ENTRIES: 'lab and test results',
  MEDICAL_EVENTS: 'medical events',
  MEDICATIONS: 'medications and supplements',
  MILITARY_HISTORY: 'military health history',
  TREATMENT_FACILITIES: 'treatment facilities',
  VACCINES: 'vaccines',
  VITALS: 'vitals and readings',
};

const generateTitleSection = (doc, parent, data) => {
  const titleSection = doc.struct('Sect', {
    title: 'Introduction',
  });
  parent.add(titleSection);
  titleSection.add(
    createHeading(doc, 'H1', config, 'Self-entered health information report', {
      x: 20,
      paragraphGap: 16,
    }),
  );
  const subTitleOptions = { lineGap: 3 };

  titleSection.add(
    doc.struct('P', () => {
      doc
        .font(config.text.font)
        .fontSize(config.text.size)
        .text(
          "This report includes health information you entered yourself in My HealtheVet. Your VA health care team can't access this self-entered information. To share this information with your care team, print this report and bring it to your next appointment.",
          20,
          doc.y,
          { ...subTitleOptions, paragraphGap: 12 },
        );
    }),
  );
  titleSection.add(
    doc.struct('P', () => {
      doc
        .font(config.text.font)
        .fontSize(config.text.size)
        .text(
          'If you want to add or edit self-entered health information, go to www.myhealth.va.gov',
          20,
          doc.y,
          { ...subTitleOptions, paragraphGap: 12 },
        );
    }),
  );
  titleSection.add(
    doc.struct('P', () => {
      doc
        .font(config.text.font)
        .fontSize(config.text.size)
        .text(`Name: ${data.name}`, 20, doc.y, subTitleOptions);
    }),
  );
  titleSection.add(
    doc.struct('P', () => {
      doc
        .font(config.text.font)
        .fontSize(config.text.size)
        .text(`Date of birth: ${data.dob}`, 20, doc.y, subTitleOptions);
    }),
  );
  titleSection.add(
    doc.struct('P', () => {
      doc
        .font(config.text.font)
        .fontSize(config.text.size)
        .text(`Last updated: ${data.lastUpdated}`, 20, doc.y, subTitleOptions);
    }),
  );

  doc.moveDown(0.75);
  titleSection.end();
};

const generateContentsSection = (doc, parent, data) => {
  const infoSection = doc.struct('Sect', {
    title: 'Information',
  });
  const missingRecordSets = Object.values(selfEnteredTypes).filter(
    type => !data.recordSets.find(set => set.type === type),
  );
  const listOptions = {
    lineGap: -2,
    paragraphGap: 6,
    listType: 'bullet',
    bulletRadius: 2,
    bulletIndent: 20,
    x: 6,
  };

  if (data.recordSets.length === 0) {
    infoSection.add(
      createHeading(
        doc,
        'H2',
        config,
        "You don't have any self-entered health information",
        { x: 20, paragraphGap: 12 },
      ),
    );
    parent.add(infoSection);
    infoSection.add(
      doc.struct('P', () => {
        doc
          .font(config.text.font)
          .fontSize(config.text.size)
          .text(
            'If you want to add or edit self-entered health information, go to www.myhealth.va.gov',
            20,
            doc.y,
            { paragraphGap: 10 },
          );
      }),
    );
  } else {
    infoSection.add(
      createHeading(
        doc,
        'H2',
        config,
        'Types of self-entered information in this report',
        { x: 20, paragraphGap: 12 },
      ),
    );
    parent.add(infoSection);
    infoSection.add(
      doc.struct('List', () => {
        doc
          .font(config.text.font)
          .fontSize(config.text.size)
          .list(
            data.recordSets.map(item => capitalize(item.type)),
            listOptions,
          );
      }),
    );
    doc.moveDown(0.75);

    if (missingRecordSets.length) {
      infoSection.add(
        createHeading(
          doc,
          'H2',
          config,
          "Types of information you haven't entered yet",
          { x: 20, paragraphGap: 12 },
        ),
      );
      infoSection.add(
        doc.struct('List', () => {
          doc
            .font(config.text.font)
            .fontSize(config.text.size)
            .list(missingRecordSets.map(type => capitalize(type)), listOptions);
        }),
      );
      doc.moveDown(0.75);
    }
  }

  infoSection.end();
};

const generateCoverPage = async (doc, parent, data) => {
  await generateTitleSection(doc, parent, data);
  await generateContentsSection(doc, parent, data);
  addHorizontalRule(doc, 20, 0.5, 0);
};

const validate = data => {
  const requiredFields = [];

  const missingFields = requiredFields.filter(field => !data[field]);
  if (missingFields.length) {
    throw new MissingFieldsException(missingFields);
  }
};

const generateRecordSetIntroduction = async (doc, parent, recordSet) => {
  const headOptions = {
    x: 20,
    paragraphGap: recordSet.titleParagraphGap ?? 10,
  };
  const subHeadOptions = { paragraphGap: 0 };
  const introduction = doc.struct('Sect', {
    title: `${recordSet.title} Introduction`,
  });
  parent.add(introduction);
  introduction.add(
    createHeading(doc, 'H2', config, recordSet.title, headOptions),
  );

  if (recordSet.subtitles) {
    for (const subtitle of recordSet.subtitles) {
      introduction.add(createSubHeading(doc, config, subtitle, subHeadOptions));
      doc.moveDown();
    }
  }

  if (recordSet.titleMoveDownAmount) {
    doc.moveDown(recordSet.titleMoveDownAmount);
  } else doc.moveDown();
  introduction.end();
};

const generateRecordTitle = (doc, parent, record) => {
  const title = doc.struct('Sect', {
    title: `Header`,
  });
  parent.add(title);

  const headOptions = { x: 20, paragraphGap: 0 };
  title.add(createHeading(doc, 'H3', config, record.title, headOptions));

  if (record.titleMoveDownAmount) doc.moveDown(record.titleMoveDownAmount);
  else doc.moveDown();
  title.end();
};

const generateDetailsContentSets = async (doc, parent, data) => {
  const details = doc.struct('Sect', {
    title: 'Details',
  });
  parent.add(details);

  for (const detail of data.details) {
    if (detail.header) {
      const headOptions = { x: 30, paragraphGap: 12 };
      details.add(createHeading(doc, 'H4', config, detail.header, headOptions));
    }
    const itemIndent = 30;
    for (const item of detail.items) {
      let structs;

      if (item.isRich) {
        structs = await createRichTextDetailItem(doc, config, itemIndent, item);
      } else {
        structs = await createDetailItem(doc, config, itemIndent, item);
      }

      for (const struct of structs) {
        details.add(struct);
      }
    }
    doc.moveDown();
  }

  doc.moveDown();
  details.end();
};

const generateDetailsContent = async (doc, parent, data) => {
  const details = doc.struct('Sect', {
    title: 'Details',
  });
  parent.add(details);
  if (data.details.header) {
    const headOptions = { x: 30, paragraphGap: 12 };
    details.add(
      createHeading(doc, 'H4', config, data.details.header, headOptions),
    );
  }
  const itemIndent = data.details.header ? 40 : 30;
  for (const item of data.details.items) {
    const structs = await createDetailItem(doc, config, itemIndent, item);
    for (const struct of structs) {
      details.add(struct);
    }
  }
  doc.moveDown();
  details.end();
};

const generateResultItemContent = async (
  item,
  doc,
  results,
  hasHorizontalRule,
  hasH2,
) => {
  const headingOptions = {
    paragraphGap: item.headerGap ?? 10,
    x: item.headerIndent || (hasH2 ? 40 : 20),
  };
  if (item.header) {
    results.add(
      await createHeading(
        doc,
        item.headerType || (hasH2 ? 'H5' : 'H3'),
        config,
        item.header,
        headingOptions,
      ),
    );
  }

  for (const resultItem of item.items) {
    let indent = item.header ? 50 : 40;
    if (!hasH2) indent = 30;
    if (item.itemsIndent) indent = item.itemsIndent;

    let structs;
    if (resultItem.isRich) {
      structs = await createRichTextDetailItem(doc, config, indent, resultItem);
    } else {
      structs = await createDetailItem(doc, config, indent, resultItem);
    }

    for (const struct of structs) {
      results.add(struct);
    }
  }

  if (hasHorizontalRule) {
    addHorizontalRule(doc, 30, 1.5, 1.5);
  }
  if (item.spaceResults) doc.moveDown(item.spaceResults);
};

export const generateResultsContent = async (doc, parent, data) => {
  const results = doc.struct('Sect', {
    title: 'Results',
  });
  parent.add(results);
  if (data.results.header) {
    const headingOptions = {
      paragraphGap: 12,
      x: data.results.headerIndent || 30,
    };
    results.add(
      createHeading(
        doc,
        data.results.headerType || 'H4',
        config,
        data.results.header,
        headingOptions,
      ),
    );
  }

  if (data.results.preface) {
    const prefaceOptions = {
      paragraphGap: 12,
      x: data.results.prefaceIndent || 30,
    };
    results.add(
      createSubHeading(doc, config, data.results.preface, prefaceOptions),
    );
  }

  const hasHorizontalRule = data.results.sectionSeparators !== false;
  const hasH2 = !!data.results.header;
  if (data.results.items.length === 1) {
    await generateResultItemContent(
      data.results.items[0],
      doc,
      results,
      hasHorizontalRule,
      hasH2,
    );
  } else {
    for (const item of data.results.items) {
      // Insert a pagebreak if the next block will not fit on the current page,
      // taking the footer height into account.
      const blockHeight = getTestResultBlockHeight(
        doc,
        item,
        hasHorizontalRule,
      );
      if (doc.y + blockHeight > 740) await doc.addPage();

      await generateResultItemContent(
        item,
        doc,
        results,
        hasHorizontalRule,
        hasH2,
      );
    }
  }
  doc.moveDown();
  results.end();
};

const generate = async data => {
  validate(data);
  const doc = createAccessibleDoc(data, config);

  await registerVaGovFonts(doc);

  doc.addPage({ margins: config.margins });

  const wrapper = doc.struct('Document');
  doc.addStructure(wrapper);

  // Add content synchronously to ensure that reading order
  // is left intact for screen reader users.
  generateInitialHeaderContent(doc, wrapper, data, config);
  await generateCoverPage(doc, wrapper, data);
  const firstPageGenerated = false;

  for (const recordSet of data.recordSets) {
    if (!firstPageGenerated) {
      doc.addPage({
        margins: {
          top: 30,
          bottom: 40,
          left: 17,
          right: 16,
        },
      });
      generateInitialHeaderContent(doc, wrapper, data, config, {
        headerBannerOnly: true,
      });
    } else {
      doc.addPage({ margins: config.margins });
    }
    generateRecordSetIntroduction(doc, wrapper, recordSet);
    if (Array.isArray(recordSet.records)) {
      for (const record of recordSet.records) {
        if (record.title) generateRecordTitle(doc, wrapper, record);

        if (Array.isArray(record.details)) {
          await generateDetailsContentSets(doc, wrapper, record);
        } else if (record.details) {
          await generateDetailsContent(doc, wrapper, record);
        }
        if (record.results) {
          await generateResultsContent(doc, wrapper, record);
        }
      }
    } else {
      const record = recordSet.records;
      if (record.details) {
        await generateDetailsContent(doc, wrapper, record);
      }
      if (record.results) {
        await generateResultsContent(doc, wrapper, record);
      }
    }
    // addHorizontalRule(doc, 20, 1.5, 1.5);
  }

  doc.font(config.text.font).fontSize(config.text.size);
  await generateFinalHeaderContent(doc, data, config);
  await generateFooterContent(doc, wrapper, data, config);

  wrapper.end();

  doc.flushPages();
  return doc;
};

export { generate };