diff --git a/services/app-api/forms/qms.ts b/services/app-api/forms/qms.ts index c33d54a1..43fd6b18 100644 --- a/services/app-api/forms/qms.ts +++ b/services/app-api/forms/qms.ts @@ -99,8 +99,14 @@ export const qmsReportTemplate: ReportTemplate = { type: ElementType.Radio, label: "Which quality measure will be reported?", value: [ - { label: "{Measure name version 1}", value: "measure-1" }, - { label: "{Measure name version 2}", value: "measure-2" }, + { + label: "{Measure name version 1}", + value: "measure-1", + }, + { + label: "{Measure name version 2}", + value: "measure-2", + }, ], }, ], diff --git a/services/app-api/handlers/banners/create.ts b/services/app-api/handlers/banners/create.ts index fd4ddbde..44297354 100644 --- a/services/app-api/handlers/banners/create.ts +++ b/services/app-api/handlers/banners/create.ts @@ -9,7 +9,7 @@ import { } from "../../libs/response-lib"; import { canWriteBanner } from "../../utils/authorization"; import { parseBannerId } from "../../libs/param-lib"; -import { validateBannerPayload } from "../../utils/validation"; +import { validateBannerPayload } from "../../utils/bannerValidation"; import { logger } from "../../libs/debug-lib"; import { BannerData } from "../../types/banner"; diff --git a/services/app-api/types/reports.ts b/services/app-api/types/reports.ts index d99d5203..c6aa9ae8 100644 --- a/services/app-api/types/reports.ts +++ b/services/app-api/types/reports.ts @@ -37,19 +37,19 @@ export interface MeasureOptions { export enum MeasureTemplateName { // required measures - "LTSS-1", - "LTSS-2", - "LTSS-6", - "LTSS-7", - "LTSS-8", + "LTSS-1" = "LTSS-1", + "LTSS-2" = "LTSS-2", + "LTSS-6" = "LTSS-6", + "LTSS-7" = "LTSS-7", + "LTSS-8" = "LTSS-8", //optional measures - "FASI-1", - "FASI-2", - "HCBS-10", - "LTSS-3", - "LTSS-4", - "LTSS-5", - "MLTSS", + "FASI-1" = "FASI-1", + "FASI-2" = "FASI-2", + "HCBS-10" = "HCBS-10", + "LTSS-3" = "LTSS-3", + "LTSS-4" = "LTSS-4", + "LTSS-5" = "LTSS-5", + "MLTSS" = "MLTSS", } export enum ReportStatus { @@ -207,12 +207,14 @@ export type TextboxTemplate = { type: ElementType.Textbox; label: string; helperText?: string; + answer?: string; }; export type DateTemplate = { type: ElementType.Date; label: string; helperText: string; + answer?: string; }; export type AccordionTemplate = { @@ -238,6 +240,7 @@ export type RadioTemplate = { label: string; helperText?: string; value: ChoiceTemplate[]; + answer?: string; }; export type ButtonLinkTemplate = { diff --git a/services/app-api/utils/validation.ts b/services/app-api/utils/bannerValidation.ts similarity index 100% rename from services/app-api/utils/validation.ts rename to services/app-api/utils/bannerValidation.ts diff --git a/services/app-api/utils/reportValidation.ts b/services/app-api/utils/reportValidation.ts new file mode 100644 index 00000000..df5da5a7 --- /dev/null +++ b/services/app-api/utils/reportValidation.ts @@ -0,0 +1,252 @@ +import { + array, + boolean, + lazy, + mixed, + number, + object, + Schema, + string, +} from "yup"; +import { + ReportStatus, + ReportType, + MeasureTemplateName, + PageType, + ElementType, + PageElement, +} from "../types/reports"; +import { error } from "./constants"; + +const headerTemplateSchema = object().shape({ + type: string().required(ElementType.Header), + text: string().required(), +}); + +const subHeaderTemplateSchema = object().shape({ + type: string().required(ElementType.SubHeader), + text: string().required(), +}); + +const paragraphTemplateSchema = object().shape({ + type: string().required(ElementType.Paragraph), + text: string().required(), + title: string().notRequired(), +}); + +const textboxTemplateSchema = object().shape({ + type: string().required(ElementType.Textbox), + label: string().required(), + helperText: string().notRequired(), + answer: string().notRequired(), +}); + +const dateTemplateSchema = object().shape({ + type: string().required(ElementType.Date), + label: string().required(), + helperText: string().required(), + answer: string().notRequired(), +}); + +const accordionTemplateSchema = object().shape({ + type: string().required(ElementType.Accordion), + label: string().required(), + value: string().required(), +}); + +const resultRowButtonTemplateSchema = object().shape({ + type: string().required(ElementType.ResultRowButton), + value: string().required(), + modalId: string().required(), + to: string().required(), +}); + +const pageElementSchema = lazy((value: PageElement): Schema => { + if (!value.type) { + throw new Error(); + } + switch (value.type) { + case ElementType.Header: + return headerTemplateSchema; + case ElementType.SubHeader: + return subHeaderTemplateSchema; + case ElementType.Paragraph: + return paragraphTemplateSchema; + case ElementType.Textbox: + return textboxTemplateSchema; + case ElementType.Date: + return dateTemplateSchema; + case ElementType.Accordion: + return accordionTemplateSchema; + case ElementType.ResultRowButton: + return resultRowButtonTemplateSchema; + case ElementType.Radio: + return radioTemplateSchema; + case ElementType.ButtonLink: + return buttonLinkTemplateSchema; + case ElementType.MeasureTable: + return measureTableTemplateSchema; + case ElementType.QualityMeasureTable: + return qualityMeasureTableTemplateSchema; + case ElementType.StatusTable: + return statusTableTemplateSchema; + default: + return mixed().notRequired(); // Fallback, although it should never be hit + } +}); + +const radioTemplateSchema = object().shape({ + type: string().required(ElementType.Radio), + label: string().required(), + helperText: string().notRequired(), + value: array().of( + object().shape({ + label: string().required(), + value: string().required(), + checked: boolean().notRequired(), + checkedChildren: lazy(() => array().of(pageElementSchema).notRequired()), + }) + ), + answer: string().notRequired(), +}); + +const buttonLinkTemplateSchema = object().shape({ + type: string().required(ElementType.ButtonLink), + label: string().required(), + to: string().required(), +}); + +const measureTableTemplateSchema = object().shape({ + type: string().required(ElementType.MeasureTable), + measureDisplay: string() + .oneOf(["required", "stratified", "optional"]) + .required(), +}); + +const qualityMeasureTableTemplateSchema = object().shape({ + type: string().required(ElementType.QualityMeasureTable), + measureDisplay: string().required("quality"), +}); + +const statusTableTemplateSchema = object().shape({ + type: string().required(ElementType.StatusTable), + to: string().required(), +}); + +const parentPageTemplateSchema = object().shape({ + id: string().required(), + childPageIds: array().of(string()).required(), +}); + +const formPageTemplateSchema = object().shape({ + id: string().required(), + title: string().required(), + type: mixed().oneOf(Object.values(PageType)).required(), + elements: array().of(pageElementSchema).required(), + sidebar: boolean().notRequired(), + hideNavButtons: boolean().notRequired(), + childPageIds: array().of(string()).notRequired(), +}); + +// MeasurePageTemplate extends FormPageTemplate +const measurePageTemplateSchema = formPageTemplateSchema.shape({ + cmit: number().notRequired(), + required: boolean().notRequired(), + stratified: boolean().notRequired(), + optional: boolean().notRequired(), + substitutable: boolean().notRequired(), +}); + +const measureOptionsArraySchema = array().of( + object().shape({ + cmit: number().required(), + required: boolean().required(), + stratified: boolean().required(), + measureTemplate: mixed() + .oneOf(Object.values(MeasureTemplateName)) + .required(), + }) +); + +const measureLookupSchema = object().shape({ + defaultMeasures: measureOptionsArraySchema, + // TODO: Add option groups +}); + +/** + * This schema is meant to represent the pages field in the ReportTemplate type. + * The following yup `lazy` function is building up the union type: + * `(ParentPageTemplate | FormPageTemplate | MeasurePageTemplate)[]` + * and outputs the correct type in the union based on various fields + * on the page object that gets passed through. + */ +const pagesSchema = array() + .of( + lazy((pageObject) => { + if (!pageObject.type) { + if (pageObject.id && pageObject.childPageIds) { + return parentPageTemplateSchema; + } else { + throw new Error(); + } + } else { + if (pageObject.type == PageType.Measure) { + return measurePageTemplateSchema; + } + return formPageTemplateSchema; + } + }) + ) + .required(); + +/** + * This schema represents a typescript type of Record + * + * The following code is looping through the MeasureTemplateName enum and building + * a yup validation object that looks like so: + * { + * [MeasureTemplateName["LTSS-1"]]: measurePageTemplateSchema, + * [MeasureTemplateName["LTSS-2"]]: measurePageTemplateSchema, + * [MeasureTemplateName["LTSS-6"]]: measurePageTemplateSchema, + * ... + * ... + * } + */ +const measureTemplatesSchema = object().shape( + Object.fromEntries( + Object.keys(MeasureTemplateName).map((meas) => [ + meas, + measurePageTemplateSchema, + ]) + ) +); + +const reportValidateSchema = object().shape({ + id: string().notRequired(), + state: string().required(), + created: number().notRequired(), + lastEdited: number().notRequired(), + lastEditedBy: string().required(), + lastEditedByEmail: string().required(), + status: mixed().oneOf(Object.values(ReportStatus)).required(), + name: string().notRequired(), + type: mixed().oneOf(Object.values(ReportType)).required(), + title: string().required(), + pages: pagesSchema, + measureLookup: measureLookupSchema, + measureTemplates: measureTemplatesSchema, +}); + +export const validateUpdateReportPayload = async ( + payload: object | undefined +) => { + if (!payload) { + throw new Error(error.MISSING_DATA); + } + + const validatedPayload = await reportValidateSchema.validate(payload, { + stripUnknown: true, + }); + + return validatedPayload; +}; diff --git a/services/app-api/utils/tests/validation.test.ts b/services/app-api/utils/tests/bannerValidation.test.ts similarity index 92% rename from services/app-api/utils/tests/validation.test.ts rename to services/app-api/utils/tests/bannerValidation.test.ts index ea6a79bc..c035425e 100644 --- a/services/app-api/utils/tests/validation.test.ts +++ b/services/app-api/utils/tests/bannerValidation.test.ts @@ -1,4 +1,4 @@ -import { validateBannerPayload } from "../validation"; +import { validateBannerPayload } from "../bannerValidation"; const validObject = { key: "1023", diff --git a/services/app-api/utils/tests/mockReport.ts b/services/app-api/utils/tests/mockReport.ts new file mode 100644 index 00000000..25859aa5 --- /dev/null +++ b/services/app-api/utils/tests/mockReport.ts @@ -0,0 +1,166 @@ +import { qmsReportTemplate } from "../../forms/qms"; +import { + Report, + PageType, + ElementType, + MeasureTemplateName, + MeasurePageTemplate, + ReportStatus, +} from "../../types/reports"; + +export const validReport: Report = { + ...qmsReportTemplate, + state: "NJ", + id: "2rRaoAFm8yLB2N2wSkTJ0iRTDu0", + created: 1736524513631, + lastEdited: 1736524513631, + lastEditedBy: "Anthony Soprano", + lastEditedByEmail: "stateuser2@test.com", + status: ReportStatus.NOT_STARTED, + name: "yeehaw", +}; + +export const missingStateReport = { + ...validReport, + state: undefined, +}; + +export const incorrectStatusReport = { + ...validReport, + status: "wrong value", // Doesn't use ReportStatus enum +}; + +export const incorrectTypeReport = { + ...validReport, + type: "wrong type", // Doesn't use ReportType enum +}; + +export const invalidMeasureTemplatesReport = { + ...validReport, + measureTemplates: { + ...qmsReportTemplate.measureTemplates, + [MeasureTemplateName["LTSS-1"]]: { + id: "LTSS-1", + title: "LTSS-1: Comprehensive Assessment and Update", + // type: PageType.Measure, + substitutable: true, + sidebar: false, + elements: [ + { + type: ElementType.ButtonLink, + label: "Return to Required Measures Dashboard", + to: "req-measure-result", + }, + ], + }, + }, +}; + +export const invalidMeasureLookupReport = { + ...validReport, + measureLookup: { + defaultMeasures: [ + { + cmit: 960, + required: true, + stratified: false, + measureTemplate: "hi", // not a MeasureTemplate enum value + }, + ], + }, +}; + +export const invalidFormPageReport = { + ...validReport, + pages: [ + { + id: "general-info", + // missing title field + type: PageType.Standard, + sidebar: true, + elements: [ + { + type: ElementType.Header, + text: "General Information", + }, + { + type: ElementType.SubHeader, + text: "State of Program Information", + }, + { + type: ElementType.Textbox, + label: "Contact title", + helperText: + "Enter person's title or a position title for CMS to contact with questions about this request.", + }, + { + type: ElementType.Textbox, + label: "Contact email address", + helperText: + "Enter email address. Department or program-wide email addresses ok.", + }, + { + type: ElementType.Date, + label: "Reporting period start date", + helperText: + "What is the reporting period Start Date applicable to the measure results?", + }, + { + type: ElementType.Date, + label: "Reporting period end date", + helperText: + "What is the reporting period End Date applicable to the measure results?", + }, + ], + }, + ], +}; + +export const invalidParentPageReport = { + ...validReport, + pages: [ + { + // missing id field + childPageIds: [ + "general-info", + "req-measure-result", + "optional-measure-result", + "review-submit", + ], + }, + ], +}; + +export const invalidRadioCheckedChildrenReport = { + ...validReport, + measureTemplates: { + ...qmsReportTemplate.measureTemplates, + [MeasureTemplateName["LTSS-1"]]: { + id: "LTSS-1", + title: "LTSS-1: Comprehensive Assessment and Update", + type: PageType.Measure, + substitutable: true, + sidebar: false, + elements: [ + { + type: ElementType.Radio, + label: "Were the reported measure results audited or validated?", + value: [ + { label: "No, I am reporting on this measure", value: "no" }, + { + label: "Yes, CMS is reporting on my behalf", + value: "yes", + checkedChildren: [ + { + // type: ElementType.Textbox, + label: + "What is the name of the agency or entity that audited or validated the report?", + }, + ], + }, + ], + }, + ], + }, + } as Record, +}; diff --git a/services/app-api/utils/tests/reportValidation.test.ts b/services/app-api/utils/tests/reportValidation.test.ts new file mode 100644 index 00000000..b5707241 --- /dev/null +++ b/services/app-api/utils/tests/reportValidation.test.ts @@ -0,0 +1,62 @@ +import { validateUpdateReportPayload } from "../reportValidation"; +import { + validReport, + missingStateReport, + incorrectStatusReport, + incorrectTypeReport, + invalidMeasureTemplatesReport, + invalidMeasureLookupReport, + invalidFormPageReport, + invalidParentPageReport, + invalidRadioCheckedChildrenReport, +} from "./mockReport"; + +describe("Test validateUpdateReportPayload function with valid report", () => { + it("successfully validates a valid report object", async () => { + const validatedData = await validateUpdateReportPayload(validReport); + expect(validatedData).toEqual(validReport); + }); +}); + +describe("Test invalid reports", () => { + it("throws an error when validating a report with missing state", () => { + expect(async () => { + await validateUpdateReportPayload(missingStateReport); + }).rejects.toThrow(); + }); + it("throws an error when validating a report with incorrect status", () => { + expect(async () => { + await validateUpdateReportPayload(incorrectStatusReport); + }).rejects.toThrow(); + }); + it("throws an error when validating a report with incorrect report type", () => { + expect(async () => { + await validateUpdateReportPayload(incorrectTypeReport); + }).rejects.toThrow(); + }); + it("throws an error when validating invalid measure templates", () => { + expect(async () => { + await validateUpdateReportPayload(invalidMeasureTemplatesReport); + }).rejects.toThrow(); + }); + it("throws an error when validating invalid measure lookup object", () => { + expect(async () => { + await validateUpdateReportPayload(invalidMeasureLookupReport); + }).rejects.toThrow(); + }); + it("throws an error when validating invalid form page object", () => { + expect(async () => { + await validateUpdateReportPayload(invalidFormPageReport); + }).rejects.toThrow(); + }); + it("throws an error when validating invalid parent page object", () => { + expect(async () => { + await validateUpdateReportPayload(invalidParentPageReport); + }).rejects.toThrow(); + }); + it("throws an error when validating invalid radio element checked children object", () => { + expect(async () => { + await validateUpdateReportPayload(invalidRadioCheckedChildrenReport); + }).rejects.toThrow(); + }); +});