diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 35659125..14c389df 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -41,16 +41,22 @@ jobs: # SAML_PRIVATE_KEY: ${{ secrets.SAML_PRIVATE_KEY }} JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }} JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} - CSB_APPLICATION_FORM_OPEN: true - CSB_PAYMENT_REQUEST_FORM_OPEN: true - CSB_CLOSE_OUT_FORM_OPEN: true - FORMIO_PKG_AUTH_TOKEN: ${{ secrets.FORMIO_PKG_AUTH_TOKEN }} + CSB_2022_FRF_OPEN: true + CSB_2022_PRF_OPEN: true + CSB_2022_CRF_OPEN: true + CSB_2023_FRF_OPEN: true + CSB_2023_PRF_OPEN: true + CSB_2023_CRF_OPEN: true + FORMIO_2022_FRF_PATH: ${{ secrets.FORMIO_2022_FRF_PATH }} + FORMIO_2022_PRF_PATH: ${{ secrets.FORMIO_2022_PRF_PATH }} + FORMIO_2022_CRF_PATH: ${{ secrets.FORMIO_2022_CRF_PATH }} + FORMIO_2023_FRF_PATH: ${{ secrets.FORMIO_2023_FRF_PATH }} + FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} + FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} - FORMIO_APPLICATION_FORM_PATH: ${{ secrets.FORMIO_APPLICATION_FORM_PATH }} - FORMIO_PAYMENT_REQUEST_FORM_PATH: ${{ secrets.FORMIO_PAYMENT_REQUEST_FORM_PATH }} - FORMIO_CLOSE_OUT_FORM_PATH: ${{ secrets.FORMIO_CLOSE_OUT_FORM_PATH }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} + FORMIO_PKG_AUTH_TOKEN: ${{ secrets.FORMIO_PKG_AUTH_TOKEN }} BAP_CLIENT_ID: ${{ secrets.BAP_CLIENT_ID }} BAP_CLIENT_SECRET: ${{ secrets.BAP_CLIENT_SECRET }} BAP_URL: ${{ secrets.BAP_URL }} @@ -124,14 +130,20 @@ jobs: # cf set-env $APP_NAME "SAML_PRIVATE_KEY" "$SAML_PRIVATE_KEY" > /dev/null cf set-env $APP_NAME "JWT_PUBLIC_KEY" "$JWT_PUBLIC_KEY" > /dev/null cf set-env $APP_NAME "JWT_PRIVATE_KEY" "$JWT_PRIVATE_KEY" > /dev/null - cf set-env $APP_NAME "CSB_APPLICATION_FORM_OPEN" "$CSB_APPLICATION_FORM_OPEN" > /dev/null - cf set-env $APP_NAME "CSB_PAYMENT_REQUEST_FORM_OPEN" "$CSB_PAYMENT_REQUEST_FORM_OPEN" > /dev/null - cf set-env $APP_NAME "CSB_CLOSE_OUT_FORM_OPEN" "$CSB_CLOSE_OUT_FORM_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_FRF_OPEN" "$CSB_2022_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_PRF_OPEN" "$CSB_2022_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_CRF_OPEN" "$CSB_2022_CRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_FRF_OPEN" "$CSB_2023_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_PRF_OPEN" "$CSB_2023_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_CRF_OPEN" "$CSB_2023_CRF_OPEN" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_FRF_PATH" "$FORMIO_2022_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_PRF_PATH" "$FORMIO_2022_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_CRF_PATH" "$FORMIO_2022_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_FRF_PATH" "$FORMIO_2023_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_PRF_PATH" "$FORMIO_2023_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_CRF_PATH" "$FORMIO_2023_CRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_BASE_URL" "$FORMIO_BASE_URL" > /dev/null cf set-env $APP_NAME "FORMIO_PROJECT_NAME" "$FORMIO_PROJECT_NAME" > /dev/null - cf set-env $APP_NAME "FORMIO_APPLICATION_FORM_PATH" "$FORMIO_APPLICATION_FORM_PATH" > /dev/null - cf set-env $APP_NAME "FORMIO_PAYMENT_REQUEST_FORM_PATH" "$FORMIO_PAYMENT_REQUEST_FORM_PATH" > /dev/null - cf set-env $APP_NAME "FORMIO_CLOSE_OUT_FORM_PATH" "$FORMIO_CLOSE_OUT_FORM_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_API_KEY" "$FORMIO_API_KEY" > /dev/null cf set-env $APP_NAME "BAP_CLIENT_ID" "$BAP_CLIENT_ID" > /dev/null cf set-env $APP_NAME "BAP_CLIENT_SECRET" "$BAP_CLIENT_SECRET" > /dev/null diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 2b64d44f..d9b69ee0 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -41,16 +41,22 @@ jobs: SAML_PRIVATE_KEY: ${{ secrets.SAML_PRIVATE_KEY }} JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }} JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} - CSB_APPLICATION_FORM_OPEN: true - CSB_PAYMENT_REQUEST_FORM_OPEN: true - CSB_CLOSE_OUT_FORM_OPEN: true - FORMIO_PKG_AUTH_TOKEN: ${{ secrets.FORMIO_PKG_AUTH_TOKEN }} + CSB_2022_FRF_OPEN: true + CSB_2022_PRF_OPEN: true + CSB_2022_CRF_OPEN: true + CSB_2023_FRF_OPEN: true + CSB_2023_PRF_OPEN: true + CSB_2023_CRF_OPEN: true + FORMIO_2022_FRF_PATH: ${{ secrets.FORMIO_2022_FRF_PATH }} + FORMIO_2022_PRF_PATH: ${{ secrets.FORMIO_2022_PRF_PATH }} + FORMIO_2022_CRF_PATH: ${{ secrets.FORMIO_2022_CRF_PATH }} + FORMIO_2023_FRF_PATH: ${{ secrets.FORMIO_2023_FRF_PATH }} + FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} + FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} - FORMIO_APPLICATION_FORM_PATH: ${{ secrets.FORMIO_APPLICATION_FORM_PATH }} - FORMIO_PAYMENT_REQUEST_FORM_PATH: ${{ secrets.FORMIO_PAYMENT_REQUEST_FORM_PATH }} - FORMIO_CLOSE_OUT_FORM_PATH: ${{ secrets.FORMIO_CLOSE_OUT_FORM_PATH }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} + FORMIO_PKG_AUTH_TOKEN: ${{ secrets.FORMIO_PKG_AUTH_TOKEN }} BAP_CLIENT_ID: ${{ secrets.BAP_CLIENT_ID }} BAP_CLIENT_SECRET: ${{ secrets.BAP_CLIENT_SECRET }} BAP_URL: ${{ secrets.BAP_URL }} @@ -124,14 +130,20 @@ jobs: cf set-env $APP_NAME "SAML_PRIVATE_KEY" "$SAML_PRIVATE_KEY" > /dev/null cf set-env $APP_NAME "JWT_PUBLIC_KEY" "$JWT_PUBLIC_KEY" > /dev/null cf set-env $APP_NAME "JWT_PRIVATE_KEY" "$JWT_PRIVATE_KEY" > /dev/null - cf set-env $APP_NAME "CSB_APPLICATION_FORM_OPEN" "$CSB_APPLICATION_FORM_OPEN" > /dev/null - cf set-env $APP_NAME "CSB_PAYMENT_REQUEST_FORM_OPEN" "$CSB_PAYMENT_REQUEST_FORM_OPEN" > /dev/null - cf set-env $APP_NAME "CSB_CLOSE_OUT_FORM_OPEN" "$CSB_CLOSE_OUT_FORM_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_FRF_OPEN" "$CSB_2022_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_PRF_OPEN" "$CSB_2022_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2022_CRF_OPEN" "$CSB_2022_CRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_FRF_OPEN" "$CSB_2023_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_PRF_OPEN" "$CSB_2023_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2023_CRF_OPEN" "$CSB_2023_CRF_OPEN" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_FRF_PATH" "$FORMIO_2022_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_PRF_PATH" "$FORMIO_2022_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2022_CRF_PATH" "$FORMIO_2022_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_FRF_PATH" "$FORMIO_2023_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_PRF_PATH" "$FORMIO_2023_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2023_CRF_PATH" "$FORMIO_2023_CRF_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_BASE_URL" "$FORMIO_BASE_URL" > /dev/null cf set-env $APP_NAME "FORMIO_PROJECT_NAME" "$FORMIO_PROJECT_NAME" > /dev/null - cf set-env $APP_NAME "FORMIO_APPLICATION_FORM_PATH" "$FORMIO_APPLICATION_FORM_PATH" > /dev/null - cf set-env $APP_NAME "FORMIO_PAYMENT_REQUEST_FORM_PATH" "$FORMIO_PAYMENT_REQUEST_FORM_PATH" > /dev/null - cf set-env $APP_NAME "FORMIO_CLOSE_OUT_FORM_PATH" "$FORMIO_CLOSE_OUT_FORM_PATH" > /dev/null cf set-env $APP_NAME "FORMIO_API_KEY" "$FORMIO_API_KEY" > /dev/null cf set-env $APP_NAME "BAP_CLIENT_ID" "$BAP_CLIENT_ID" > /dev/null cf set-env $APP_NAME "BAP_CLIENT_SECRET" "$BAP_CLIENT_SECRET" > /dev/null diff --git a/app/client/package-lock.json b/app/client/package-lock.json index 9d53a3f7..e45462ef 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -15,6 +15,7 @@ "@headlessui/react": "1.7.14", "@heroicons/react": "2.0.18", "@radix-ui/react-tooltip": "1.0.5", + "@tailwindcss/forms": "0.5.3", "@tanstack/react-query": "4.29.7", "@tanstack/react-query-devtools": "4.29.7", "@testing-library/jest-dom": "5.16.5", @@ -3624,6 +3625,17 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.3.tgz", + "integrity": "sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, "node_modules/@tanstack/match-sorter-utils": { "version": "8.7.6", "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz", @@ -13148,6 +13160,14 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -21307,6 +21327,14 @@ "loader-utils": "^2.0.0" } }, + "@tailwindcss/forms": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.3.tgz", + "integrity": "sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==", + "requires": { + "mini-svg-data-uri": "^1.2.3" + } + }, "@tanstack/match-sorter-utils": { "version": "8.7.6", "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.6.tgz", @@ -28214,6 +28242,11 @@ } } }, + "mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==" + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", diff --git a/app/client/package.json b/app/client/package.json index 3dbb49d3..9854c6e2 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -23,6 +23,7 @@ "@headlessui/react": "1.7.14", "@heroicons/react": "2.0.18", "@radix-ui/react-tooltip": "1.0.5", + "@tailwindcss/forms": "0.5.3", "@tanstack/react-query": "4.29.7", "@tanstack/react-query-devtools": "4.29.7", "@testing-library/jest-dom": "5.16.5", diff --git a/app/client/src/components/app.tsx b/app/client/src/components/app.tsx index 043eefc5..1884abcc 100644 --- a/app/client/src/components/app.tsx +++ b/app/client/src/components/app.tsx @@ -3,6 +3,7 @@ import { render } from "react-dom"; import { createBrowserRouter, createRoutesFromElements, + redirect, Navigate, Route, RouterProvider, @@ -38,11 +39,12 @@ import { UserDashboard } from "components/userDashboard"; import { ConfirmationDialog } from "components/confirmationDialog"; import { Notifications } from "components/notifications"; import { Helpdesk } from "routes/helpdesk"; -import { AllRebates } from "routes/allRebates"; -import { NewApplicationForm } from "routes/newApplicationForm"; -import { ApplicationForm } from "routes/applicationForm"; -import { PaymentRequestForm } from "routes/paymentRequestForm"; -import { CloseOutForm } from "routes/closeOutForm"; +import { Submissions } from "routes/submissions"; +import { FRFNew } from "routes/frfNew"; +import { FRF2022 } from "routes/frf2022"; +import { PRF2022 } from "routes/prf2022"; +import { CRF2022 } from "routes/crf2022"; +import { FRF2023 } from "routes/frf2023"; import { useDialogState, useDialogActions } from "contexts/dialog"; /** Custom hook to display a site-wide alert banner */ @@ -238,12 +240,36 @@ export function App() { }> } /> }> - } /> + } /> + } /> - } /> - } /> - } /> - } /> + + {/* Redirect pre-v4 routes to use post-v4 routes */} + redirect(`/frf/new`)} + /> + redirect(`/frf/2022/${params.id}`)} + /> + redirect(`/prf/2022/${params.id}`)} + /> + redirect(`/crf/2022/${params.id}`)} + /> + + } /> + + } /> + } /> + } /> + + } /> + } /> diff --git a/app/client/src/components/userDashboard.tsx b/app/client/src/components/userDashboard.tsx index dc078136..6b3f0fcc 100644 --- a/app/client/src/components/userDashboard.tsx +++ b/app/client/src/components/userDashboard.tsx @@ -5,12 +5,7 @@ import uswds from "@formio/uswds"; import icons from "uswds/img/sprite.svg"; // --- import { serverUrlForHrefs, formioBaseUrl, formioProjectUrl } from "../config"; -import { - useCsbQuery, - useBapSamQuery, - useCsbData, - useBapSamData, -} from "../utilities"; +import { useConfigQuery, useBapSamQuery, useBapSamData } from "../utilities"; import { useHelpdeskAccess } from "components/app"; import { Loading } from "components/loading"; import { useDialogActions } from "contexts/dialog"; @@ -20,37 +15,35 @@ Formio.setProjectUrl(formioProjectUrl); Formio.use(premium); Formio.use(uswds); -function IconText(props: { - order: "icon-text" | "text-icon"; - icon: string; - text: string; -}) { - const { order, icon, text } = props; - - const Icon = ( - - - +function DashboardIconText() { + return ( + + + + + Dashboard + ); +} - const Text = ( - - {text} +function HelpdeskIconText() { + return ( + + + + + Helpdesk ); +} +function SignOutIconText() { return ( - {order === "icon-text" ? [Icon, Text] : [Text, Icon]} + Sign out + + + ); } @@ -61,26 +54,41 @@ export function UserDashboard(props: { email: string }) { const { pathname } = useLocation(); const navigate = useNavigate(); - useCsbQuery(); + useConfigQuery(); useBapSamQuery(); - const csbData = useCsbData(); const bapSamData = useBapSamData(); const { displayDialog } = useDialogActions(); const helpdeskAccess = useHelpdeskAccess(); - const onAllRebatesPage = pathname === "/"; + const onSubmissionsPage = pathname === "/"; const onHelpdeskPage = pathname === "/helpdesk"; - const onApplicationFormPage = pathname.startsWith("/rebate"); - const onPaymentRequestFormPage = pathname.startsWith("/payment-request"); - const onCloseOutFormPage = pathname.startsWith("/close-out"); - - const applicationFormOpen = csbData - ? csbData.submissionPeriodOpen.application - : false; + const onFormPage = + pathname.startsWith("/frf") || + pathname.startsWith("/prf") || + pathname.startsWith("/crf"); + + const btnClassNames = + "usa-button margin-0 padding-x-2 padding-y-1 width-full font-sans-2xs"; + + function confirmationNavigation(url: string) { + displayDialog({ + dismissable: true, + heading: "Are you sure you want to navigate away from this page?", + description: ( + + If you haven’t saved the current form, any changes you’ve made will be + lost. + + ), + confirmText: "Yes", + dismissText: "Cancel", + confirmedAction: () => navigate(url), + }); + } - if (!csbData || !bapSamData) { + if (!bapSamData) { return ; } @@ -109,141 +117,64 @@ export function UserDashboard(props: { email: string }) { - - - - {email} - - - - - - - - - {/* --- Your Rebate Forms --- */} - {onAllRebatesPage ? ( - - - - ) : ( - { - if ( - onApplicationFormPage || - onPaymentRequestFormPage || - onCloseOutFormPage - ) { - ev.preventDefault(); - displayDialog({ - dismissable: true, - heading: - "Are you sure you want to navigate away from this page?", - description: ( - - If you haven’t saved the current form, any changes - you’ve made will be lost. - - ), - confirmText: "Yes", - dismissText: "Cancel", - confirmedAction: () => navigate("/"), - }); - } - }} - > - - - )} + + + + {onSubmissionsPage ? ( + + + + ) : ( + { + if (onFormPage) { + ev.preventDefault(); + confirmationNavigation("/"); + } + }} + > + + + )} + - {/* --- New Application --- */} - {onApplicationFormPage || - onPaymentRequestFormPage || - onCloseOutFormPage || - !applicationFormOpen ? ( - - - - ) : ( - - - - )} - - {/* --- Helpdesk --- */} {helpdeskAccess === "success" && ( - <> + {onHelpdeskPage ? ( - - + + ) : ( { - if ( - onApplicationFormPage || - onPaymentRequestFormPage || - onCloseOutFormPage - ) { + if (onFormPage) { ev.preventDefault(); - displayDialog({ - dismissable: true, - heading: - "Are you sure you want to navigate away from this page?", - description: ( - - If you haven’t saved the current form, any changes - you’ve made will be lost. - - ), - confirmText: "Yes", - dismissText: "Cancel", - confirmedAction: () => navigate("/helpdesk"), - }); + confirmationNavigation("/helpdesk"); } }} > - + )} - > + )} + + + + {email} + + + + + + + + diff --git a/app/client/src/config.tsx b/app/client/src/config.tsx index 2cc4f4a9..90f9a152 100644 --- a/app/client/src/config.tsx +++ b/app/client/src/config.tsx @@ -69,12 +69,10 @@ export const messages = { timeout: "For security reasons, you have been logged out due to 15 minutes of inactivity.", logout: "You have successfully logged out.", - applicationFormClosed: - "The CSB Application form enrollment period is closed.", - paymentRequestFormClosed: - "The CSB Payment Request form enrollment period is closed.", - closeOutFormClosed: "The CSB Close Out form enrollment period is closed.", - paymentRequestFormWillBeDeleted: + frfClosed: "The CSB Application form enrollment period is closed.", + prfClosed: "The CSB Payment Request form enrollment period is closed.", + crfClosed: "The CSB Close Out form enrollment period is closed.", + prfWillBeDeleted: "A request to edit the Application form associated with this draft or submitted Payment Request form has been made, so this form has been set to read-only mode. Visit your dashboard to make edits to the associated Application form submission.", }; @@ -87,27 +85,27 @@ export const formioStatusMap = new Map() .set("submitted", "Submitted"); /** - * BAP internal to external status mapping for Application form submissions. + * BAP internal to external status mapping for FRF submissions. */ -export const bapApplicationStatusMap = new Map() +export const bapFRFStatusMap = new Map() .set("Needs Clarification", "Needs Clarification") .set("Withdrawn", "Withdrawn") .set("Coordinator Denied", "Not Selected") .set("Accepted", "Selected"); /** - * BAP internal to external status mapping for Payment Request form submissions. + * BAP internal to external status mapping for PRF submissions. */ -export const bapPaymentRequestStatusMap = new Map() +export const bapPRFStatusMap = new Map() .set("Needs Clarification", "Needs Clarification") .set("Withdrawn", "Withdrawn") .set("Coordinator Denied", "Funding Not Approved") .set("Accepted", "Funding Approved"); /** - * BAP internal to external status mapping for Close Out form submissions. + * BAP internal to external status mapping for CRF submissions. */ -export const bapCloseOutStatusMap = new Map() +export const bapCRFStatusMap = new Map() .set("Needs Clarification", "Needs Clarification") .set("Reimbursement Needed", "Reimbursement Needed") .set("Branch Director Denied", "Close Out Not Approved") diff --git a/app/client/src/contexts/rebateYear.tsx b/app/client/src/contexts/rebateYear.tsx new file mode 100644 index 00000000..151d2fdf --- /dev/null +++ b/app/client/src/contexts/rebateYear.tsx @@ -0,0 +1,101 @@ +import { + Dispatch, + ReactNode, + createContext, + useContext, + useReducer, +} from "react"; + +type Props = { + children: ReactNode; +}; + +export type RebateYear = "2022" | "2023"; + +type State = { + rebateYear: RebateYear; +}; + +type Action = { + type: "SET_REBATE_YEAR"; + payload: { rebateYear: RebateYear }; +}; + +const StateContext = createContext(undefined); +const DispatchContext = createContext | undefined>(undefined); + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "SET_REBATE_YEAR": { + const { rebateYear } = action.payload; + + return { + ...state, + rebateYear, + }; + } + + default: { + const message = `Unhandled action type: ${action}`; + throw new Error(message); + } + } +} + +export function RebateYearProvider({ children }: Props) { + const initialState: State = { + rebateYear: "2022", + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + return ( + + + {children} + + + ); +} + +/** + * Returns state stored in `RebateYearProvider` context component. + */ +export function useRebateYearState() { + const context = useContext(StateContext); + if (context === undefined) { + const message = `useRebateYearState must be called within an RebateYearProvider`; + throw new Error(message); + } + return context; +} + +/** + * Custom hook that returns `dispatch` method for dispatching actions to update + * state stored in `RebateYearProvider` context component. + */ +function useRebateYearDispatch() { + const context = useContext(DispatchContext); + if (context === undefined) { + const message = `useRebateYearDispatch must be used within a RebateYearProvider`; + throw new Error(message); + } + return context; +} + +/** + * Custom hook that returns a function to dispatch an action to set the rebate + * year. + */ +export function useRebateYearActions() { + const dispatch = useRebateYearDispatch(); + + return { + setRebateYear(rebateYear: RebateYear) { + dispatch({ + type: "SET_REBATE_YEAR", + payload: { rebateYear }, + }); + }, + }; +} diff --git a/app/client/src/index.tsx b/app/client/src/index.tsx index dc5e14a8..4fba5dc1 100644 --- a/app/client/src/index.tsx +++ b/app/client/src/index.tsx @@ -7,6 +7,7 @@ import { ErrorBoundary } from "components/errorBoundary"; import { App } from "components/app"; import { DialogProvider } from "contexts/dialog"; import { NotificationsProvider } from "contexts/notifications"; +import { RebateYearProvider } from "contexts/rebateYear"; import "./tailwind-preflight.css"; import "./styles.css"; @@ -40,7 +41,9 @@ function Index() { - + + + diff --git a/app/client/src/routes/allRebates.tsx b/app/client/src/routes/allRebates.tsx deleted file mode 100644 index b041cc90..00000000 --- a/app/client/src/routes/allRebates.tsx +++ /dev/null @@ -1,909 +0,0 @@ -import { Fragment, useState } from "react"; -import type { LinkProps } from "react-router-dom"; -import { Link, useNavigate, useOutletContext } from "react-router-dom"; -import icons from "uswds/img/sprite.svg"; -// --- -import { serverUrl, messages } from "../config"; -import { - Rebate, - postData, - useContentData, - useCsbData, - useBapSamData, - useSubmissionsQueries, - useRebates, - submissionNeedsEdits, - getUserInfo, -} from "../utilities"; -import { Loading, LoadingButtonIcon } from "components/loading"; -import { Message } from "components/message"; -import { MarkdownContent } from "components/markdownContent"; -import { TextWithTooltip } from "components/tooltip"; -import { useNotificationsActions } from "contexts/notifications"; - -const defaultTableRowClassNames = "bg-gray-5"; -const highlightedTableRowClassNames = "bg-primary-lighter"; - -function ButtonLink(props: { type: "edit" | "view"; to: LinkProps["to"] }) { - const icon = props.type === "edit" ? "edit" : "visibility"; - const text = props.type === "edit" ? "Edit" : "View"; - const linkClassNames = - `usa-button ` + - `${props.type === "view" ? "usa-button--base " : ""}` + - `font-sans-2xs margin-right-0 padding-x-105 padding-y-1`; - - return ( - - - - - - {text} - - - ); -} - -function ApplicationSubmission(props: { rebate: Rebate }) { - const { rebate } = props; - const { application, paymentRequest, closeOut } = rebate; - - const csbData = useCsbData(); - - if (!csbData) return null; - - const applicationFormOpen = csbData.submissionPeriodOpen.application; - - const { - applicantUEI, - applicantEfti, - applicantEfti_display, - applicantOrganizationName, - schoolDistrictName, - last_updated_by, - } = application.formio.data; - - const date = new Date(application.formio.modified).toLocaleDateString(); - const time = new Date(application.formio.modified).toLocaleTimeString(); - - const applicationNeedsEdits = submissionNeedsEdits({ - formio: application.formio, - bap: application.bap, - }); - - const applicationNeedsClarification = - application.bap?.status === "Needs Clarification"; - - const applicationHasBeenWithdrawn = application.bap?.status === "Withdrawn"; - - const applicationNotSelected = - application.bap?.status === "Coordinator Denied"; - - const applicationSelected = application.bap?.status === "Accepted"; - - const applicationSelectedButNoPaymentRequest = - applicationSelected && !Boolean(paymentRequest.formio); - - const paymentRequestFundingApproved = - paymentRequest.bap?.status === "Accepted"; - - const paymentRequestFundingApprovedButNoCloseOut = - paymentRequestFundingApproved && !Boolean(closeOut.formio); - - const statusTableCellClassNames = - application.formio.state === "submitted" || !applicationFormOpen - ? "text-italic" - : ""; - - const statusIconClassNames = applicationSelected - ? "usa-icon text-primary" // blue - : "usa-icon"; - - const statusIcon = applicationNeedsEdits - ? `${icons}#priority_high` // ! - : applicationHasBeenWithdrawn - ? `${icons}#close` // ✕ - : applicationNotSelected - ? `${icons}#cancel` // x inside a circle - : applicationSelected - ? `${icons}#check_circle` // check inside a circle - : application.formio.state === "draft" - ? `${icons}#more_horiz` // three horizontal dots - : application.formio.state === "submitted" - ? `${icons}#check` // check - : `${icons}#remove`; // — (fallback, not used) - - const statusText = applicationNeedsEdits - ? "Edits Requested" - : applicationHasBeenWithdrawn - ? "Withdrawn" - : applicationNotSelected - ? "Not Selected" - : applicationSelected - ? "Selected" - : application.formio.state === "draft" - ? "Draft" - : application.formio.state === "submitted" - ? "Submitted" - : ""; // fallback, not used - - const applicationFormUrl = `/rebate/${application.formio._id}`; - - /** - * NOTE on the usage of `TextWithTooltip` below: - * When a form is first initially created, and the user has not yet clicked - * the "Next" or "Save" buttons, any fields that the Formio form definition - * sets automatically (based on hidden fields we inject on form creation) will - * not yet be part of the form submission data. As soon as the user clicks the - * "Next" or "Save" buttons the first time, those fields will be set and - * stored in the submission. Since we display some of those fields in the - * table below, we need to check if their values exist, and if they don't (for - * cases where the user has not yet advanced past the first screen of the - * form...which we believe is a bit of an edge case, as most users will likely - * do that after starting a new application), indicate to the user they need - * to first save the form for the fields to be displayed. - */ - return ( - - - {applicationNeedsEdits ? ( - - ) : application.formio.state === "submitted" || !applicationFormOpen ? ( - - ) : application.formio.state === "draft" ? ( - - ) : null} - - - - {application.bap?.rebateId ? ( - - {application.bap.rebateId} - - ) : ( - - )} - - - - Application - - - {applicationNeedsClarification ? ( - - ) : ( - <> - - - - {statusText} - > - )} - - - - - <> - {Boolean(applicantUEI) ? ( - applicantUEI - ) : ( - - )} - - { - /* NOTE: -Initial version of the application form definition included the `applicantEfti` -field, which is configured via the form definition (in Formio) to set its value -based on the value of the `sam_hidden_applicant_efti` field, which we inject on -initial form submission. That value comes from the BAP (SAM.gov data), which -could be an empty string. - -To handle the potentially empty string, the Formio form definition was updated -to include a new `applicantEfti_display` field that's configured in the form -definition to set it's value to the string '0000' if the `applicantEfti` field's -value is an empty string. This logic (again, built into the form definition) -works great for new form submissions that have taken place after the form -definition has been updated to include this `applicantEfti_display` field... */ - Boolean(applicantEfti_display) ? ( - applicantEfti_display - ) : /* NOTE: -...but we need to handle old/existing submissions that were submitted before the -form definition was updated to include the new `applicantEfti_display` field, -and where the user has already advanced past the first screen (e.g. they've hit -the "Next" or "Save" buttons at least once). - -At this point the form definition logic has already kicked in that sets the -`applicaitonEfti` field, but it's value _could_ be an empty string (it won't -necessairly be, but it could be). Since the `applicantEfti` field's value could -be an empty string (which is falsy in JavaScript), we need to check another -field's value that will also set at this point, and whose value will always be -truthy. We'll check the `applicantUEI` field's value, as it's value will always -be set for users that have advanced past the first screen (we could have just as -easily used another field, like the `applicantOrganizationName` field for the -same result). */ - Boolean(applicantUEI) ? ( - /* NOTE: -If the `applicantUEI` field's value is truthy, we know the user has advanced -past the first screen, so we'll render the value of the `applicantEfti` field, -and fall back to "0000", which will be used in cases where the `applicantEfti` -field's value is an empty string. */ - applicantEfti || "0000" - ) : ( - /* NOTE: -At this point in the conditional logic, we know the user has not advanced past -the first screen, so we'll render the tooltip, indicating the user must edit and -save the form for the EFT indicator to be displayed. */ - - ) - } - > - - - - <> - {Boolean(applicantOrganizationName) ? ( - applicantOrganizationName - ) : ( - - )} - - {Boolean(schoolDistrictName) ? ( - schoolDistrictName - ) : ( - - )} - > - - - - {last_updated_by} - - {date} - - - ); -} - -function PaymentRequestSubmission(props: { rebate: Rebate }) { - const { rebate } = props; - const { application, paymentRequest, closeOut } = rebate; - - const navigate = useNavigate(); - const { email } = useOutletContext<{ email: string }>(); - - const csbData = useCsbData(); - const bapSamData = useBapSamData(); - const { displayErrorNotification } = useNotificationsActions(); - - /** - * Stores when data is being posted to the server, so a loading indicator can - * be rendered inside the "New Payment Request" button, and we can prevent - * double submits/creations of new Payment Request form submissions. - */ - const [dataIsPosting, setDataIsPosting] = useState(false); - - if (!csbData || !bapSamData) return null; - - const paymentRequestFormOpen = csbData.submissionPeriodOpen.paymentRequest; - - const applicationSelected = application.bap?.status === "Accepted"; - - const applicationSelectedButNoPaymentRequest = - applicationSelected && !Boolean(paymentRequest.formio); - - /** matched SAM.gov entity for the Application submission */ - const entity = bapSamData.entities.find((entity) => { - return ( - entity.ENTITY_STATUS__c === "Active" && - entity.ENTITY_COMBO_KEY__c === - application.formio.data.bap_hidden_entity_combo_key - ); - }); - - if (applicationSelectedButNoPaymentRequest) { - return ( - - - { - if (!application.bap || !entity) return; - - // account for when data is posting to prevent double submits - if (dataIsPosting) return; - setDataIsPosting(true); - - const { title, name } = getUserInfo(email, entity); - - // create a new draft Payment Request form submission - postData(`${serverUrl}/api/formio-payment-request-submission/`, { - email, - title, - name, - entity, - comboKey: application.bap.comboKey, - rebateId: application.bap.rebateId, // CSB Rebate ID (6 digits) - applicationReviewItemId: application.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) - applicationFormModified: application.bap.modified, - }) - .then((res) => { - navigate(`/payment-request/${application.bap?.rebateId}`); - }) - .catch((err) => { - displayErrorNotification({ - id: Date.now(), - body: ( - <> - - Error creating Payment Request{" "} - {application.bap?.rebateId}. - - - Please try again. - - > - ), - }); - }) - .finally(() => { - setDataIsPosting(false); - }); - }} - > - - - - - New Payment Request - {dataIsPosting && } - - - - - ); - } - - // return if a Payment Request submission has not been created for this rebate - if (!paymentRequest.formio) return null; - - const { hidden_current_user_email, hidden_bap_rebate_id } = - paymentRequest.formio.data; - - const date = new Date(paymentRequest.formio.modified).toLocaleDateString(); - const time = new Date(paymentRequest.formio.modified).toLocaleTimeString(); - - const applicationNeedsEdits = submissionNeedsEdits({ - formio: application.formio, - bap: application.bap, - }); - - const paymentRequestNeedsEdits = submissionNeedsEdits({ - formio: paymentRequest.formio, - bap: paymentRequest.bap, - }); - - const paymentRequestNeedsClarification = - paymentRequest.bap?.status === "Needs Clarification"; - - const paymentRequestHasBeenWithdrawn = - paymentRequest.bap?.status === "Withdrawn"; - - const paymentRequestFundingNotApproved = - paymentRequest.bap?.status === "Coordinator Denied"; - - const paymentRequestFundingApproved = - paymentRequest.bap?.status === "Accepted"; - - const paymentRequestFundingApprovedButNoCloseOut = - paymentRequestFundingApproved && !Boolean(closeOut.formio); - - const statusTableCellClassNames = - paymentRequest.formio.state === "submitted" || !paymentRequestFormOpen - ? "text-italic" - : ""; - - const statusIconClassNames = paymentRequestFundingApproved - ? "usa-icon text-primary" // blue - : "usa-icon"; - - const statusIcon = paymentRequestNeedsEdits - ? `${icons}#priority_high` // ! - : paymentRequestHasBeenWithdrawn - ? `${icons}#close` // ✕ - : paymentRequestFundingNotApproved - ? `${icons}#cancel` // ✕ inside a circle - : paymentRequestFundingApproved - ? `${icons}#check_circle` // check inside a circle - : paymentRequest.formio.state === "draft" - ? `${icons}#more_horiz` // three horizontal dots - : paymentRequest.formio.state === "submitted" - ? `${icons}#check` // check - : `${icons}#remove`; // — (fallback, not used) - - const statusText = paymentRequestNeedsEdits - ? "Edits Requested" - : paymentRequestHasBeenWithdrawn - ? "Withdrawn" - : paymentRequestFundingNotApproved - ? "Funding Not Approved" - : paymentRequestFundingApproved - ? "Funding Approved" - : paymentRequest.formio.state === "draft" - ? "Draft" - : paymentRequest.formio.state === "submitted" - ? "Submitted" - : ""; // fallback, not used - - const paymentRequestFormUrl = `/payment-request/${hidden_bap_rebate_id}`; - - return ( - - - {applicationNeedsEdits ? ( - - ) : paymentRequestNeedsEdits ? ( - - ) : paymentRequest.formio.state === "submitted" || - !paymentRequestFormOpen ? ( - - ) : paymentRequest.formio.state === "draft" ? ( - - ) : null} - - - - - - Payment Request - - - {paymentRequestNeedsClarification ? ( - - ) : ( - <> - - - - {statusText} - > - )} - - - - - - - - - {hidden_current_user_email} - - {date} - - - ); -} - -function CloseOutSubmission(props: { rebate: Rebate }) { - const { rebate } = props; - const { application, paymentRequest, closeOut } = rebate; - - const navigate = useNavigate(); - const { email } = useOutletContext<{ email: string }>(); - - const csbData = useCsbData(); - const bapSamData = useBapSamData(); - const { displayErrorNotification } = useNotificationsActions(); - - /** - * Stores when data is being posted to the server, so a loading indicator can - * be rendered inside the "New Close Out" button, and we can prevent double - * submits/creations of new Close Out form submissions. - */ - const [dataIsPosting, setDataIsPosting] = useState(false); - - if (!csbData || !bapSamData) return null; - - const closeOutFormOpen = csbData.submissionPeriodOpen.closeOut; - - const paymentRequestFundingApproved = - paymentRequest.bap?.status === "Accepted"; - - const paymentRequestFundingApprovedButNoCloseOut = - paymentRequestFundingApproved && !Boolean(closeOut.formio); - - /** matched SAM.gov entity for the Payment Request submission */ - const entity = bapSamData.entities.find((entity) => { - return ( - entity.ENTITY_STATUS__c === "Active" && - entity.ENTITY_COMBO_KEY__c === - paymentRequest.formio?.data.bap_hidden_entity_combo_key - ); - }); - - if (paymentRequestFundingApprovedButNoCloseOut) { - return ( - - - { - if (!application.bap || !paymentRequest.bap || !entity) return; - - // account for when data is posting to prevent double submits - if (dataIsPosting) return; - setDataIsPosting(true); - - const { title, name } = getUserInfo(email, entity); - - // create a new draft Close Out form submission - postData(`${serverUrl}/api/formio-close-out-submission/`, { - email, - title, - name, - entity, - comboKey: paymentRequest.bap.comboKey, - rebateId: paymentRequest.bap.rebateId, // CSB Rebate ID (6 digits) - applicationReviewItemId: application.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) - paymentRequestReviewItemId: paymentRequest.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) - paymentRequestFormModified: paymentRequest.bap.modified, - }) - .then((res) => { - navigate(`/close-out/${paymentRequest.bap?.rebateId}`); - }) - .catch((err) => { - displayErrorNotification({ - id: Date.now(), - body: ( - <> - - Error creating Close Out{" "} - {paymentRequest.bap?.rebateId}. - - - Please try again. - - > - ), - }); - }) - .finally(() => { - setDataIsPosting(false); - }); - }} - > - - - - - New Close Out - {dataIsPosting && } - - - - - ); - } - - // return if a Close Out submission has not been created for this rebate - if (!closeOut.formio) return null; - - const { hidden_current_user_email, hidden_bap_rebate_id } = - closeOut.formio.data; - - const date = new Date(closeOut.formio.modified).toLocaleDateString(); - const time = new Date(closeOut.formio.modified).toLocaleTimeString(); - - const closeOutNeedsEdits = submissionNeedsEdits({ - formio: closeOut.formio, - bap: closeOut.bap, - }); - - const closeOutNeedsClarification = - closeOut.bap?.status === "Needs Clarification"; - - const closeOutReimbursementNeeded = - closeOut.bap?.status === "Reimbursement Needed"; - - const closeOutNotApproved = closeOut.bap?.status === "Branch Director Denied"; - - const closeOutApproved = closeOut.bap?.status === "Branch Director Approved"; - - const statusTableCellClassNames = - closeOut.formio.state === "submitted" || !closeOutFormOpen - ? "text-italic" - : ""; - - const statusIconClassNames = closeOutApproved - ? "usa-icon text-primary" // blue - : "usa-icon"; - - const statusIcon = closeOutNeedsEdits - ? `${icons}#priority_high` // ! - : closeOutNotApproved - ? `${icons}#cancel` // ✕ inside a circle - : closeOutApproved - ? `${icons}#check_circle` // check inside a circle - : closeOut.formio.state === "draft" - ? `${icons}#more_horiz` // three horizontal dots - : closeOut.formio.state === "submitted" - ? `${icons}#check` // check - : `${icons}#remove`; // — (fallback, not used) - - const statusText = closeOutNeedsEdits - ? "Edits Requested" - : closeOutNotApproved - ? "Close Out Not Approved" - : closeOutApproved - ? "Close Out Approved" - : closeOut.formio.state === "draft" - ? "Draft" - : closeOut.formio.state === "submitted" - ? "Submitted" - : ""; // fallback, not used - - const closeOutFormUrl = `/close-out/${hidden_bap_rebate_id}`; - - return ( - - - {closeOutNeedsEdits ? ( - - ) : closeOut.formio.state === "submitted" || !closeOutFormOpen ? ( - - ) : closeOut.formio.state === "draft" ? ( - - ) : null} - - - - - - Close Out - - - {closeOutNeedsClarification ? ( - - ) : closeOutReimbursementNeeded ? ( - - ) : ( - <> - - - - {statusText} - > - )} - - - - - - - - - {hidden_current_user_email} - - {date} - - - ); -} - -export function AllRebates() { - const content = useContentData(); - const bapSamData = useBapSamData(); - const submissionsQueries = useSubmissionsQueries(); - const rebates = useRebates(); - - if (!bapSamData) return null; - - if (submissionsQueries.some((query) => query.isFetching)) { - return ; - } - - if (submissionsQueries.some((query) => query.isError)) { - return ; - } - - return ( - <> - {bapSamData.entities.some((e) => e.ENTITY_STATUS__c !== "Active") && ( - - )} - - {rebates.length === 0 ? ( - - - - ) : ( - <> - {content && ( - - )} - - - - - - - Open - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {rebates.map((rebate, index) => ( - - - - - {/* blank row after all rebates but the last one */} - {index !== rebates.length - 1 && ( - - - - - - )} - - ))} - - - - > - )} - - {content && ( - - )} - > - ); -} diff --git a/app/client/src/routes/closeOutForm.tsx b/app/client/src/routes/crf2022.tsx similarity index 90% rename from app/client/src/routes/closeOutForm.tsx rename to app/client/src/routes/crf2022.tsx index 55bf6835..472c4efc 100644 --- a/app/client/src/routes/closeOutForm.tsx +++ b/app/client/src/routes/crf2022.tsx @@ -9,14 +9,14 @@ import icons from "uswds/img/sprite.svg"; // --- import { serverUrl, messages } from "../config"; import { - FormioCloseOutSubmission, + FormioCRF2022Submission, getData, postData, useContentData, - useCsbData, + useConfigData, useBapSamData, useSubmissionsQueries, - useRebates, + useSubmissions, submissionNeedsEdits, getUserInfo, } from "../utilities"; @@ -24,6 +24,7 @@ import { Loading } from "components/loading"; import { Message } from "components/message"; import { MarkdownContent } from "components/markdownContent"; import { useNotificationsActions } from "contexts/notifications"; +import { useRebateYearState } from "contexts/rebateYear"; type ServerResponse = | { @@ -34,7 +35,7 @@ type ServerResponse = | { userAccess: true; formSchema: { url: string; json: object }; - submission: FormioCloseOutSubmission; + submission: FormioCRF2022Submission; }; /** Custom hook to fetch Formio submission data */ @@ -42,13 +43,13 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const queryClient = useQueryClient(); useEffect(() => { - queryClient.resetQueries({ queryKey: ["close-out"] }); + queryClient.resetQueries({ queryKey: ["formio/2022/crf-submission"] }); }, [queryClient]); - const url = `${serverUrl}/api/formio-close-out-submission/${rebateId}`; + const url = `${serverUrl}/api/formio/2022/crf-submission/${rebateId}`; const query = useQuery({ - queryKey: ["close-out", { id: rebateId }], + queryKey: ["formio/2022/crf-submission", { id: rebateId }], queryFn: () => { return getData(url).then((res) => { const mongoId = res.submission?._id; @@ -64,7 +65,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { */ Formio.Providers.providers.storage.s3 = function (formio: any) { const s3Formio = cloneDeep(formio); - s3Formio.formUrl = `${serverUrl}/api/s3/close-out/${mongoId}/${comboKey}`; + s3Formio.formUrl = `${serverUrl}/api/formio/2022/s3/crf/${mongoId}/${comboKey}`; return s3(s3Formio); }; @@ -83,11 +84,11 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { state: "submitted" | "draft"; }; }) => { - return postData(url, updatedSubmission); + return postData(url, updatedSubmission); }, onSuccess: (res) => { return queryClient.setQueryData( - ["close-out", { id: rebateId }], + ["formio/2022/crf-submission", { id: rebateId }], (prevData) => { return prevData?.submission ? { ...prevData, submission: res } @@ -100,31 +101,32 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { return { query, mutation }; } -export function CloseOutForm() { +export function CRF2022() { const { email } = useOutletContext<{ email: string }>(); /* ensure user verification (JWT refresh) doesn't cause form to re-render */ return useMemo(() => { - return ; + return ; }, [email]); } -function UserCloseOutForm(props: { email: string }) { +function CloseOutRequestForm(props: { email: string }) { const { email } = props; const navigate = useNavigate(); const { id: rebateId } = useParams<"id">(); // CSB Rebate ID (6 digits) const content = useContentData(); - const csbData = useCsbData(); + const configData = useConfigData(); const bapSamData = useBapSamData(); const { displaySuccessNotification, displayErrorNotification, dismissNotification, } = useNotificationsActions(); + const { rebateYear } = useRebateYearState(); - const submissionsQueries = useSubmissionsQueries(); - const rebates = useRebates(); + const submissionsQueries = useSubmissionsQueries("2022"); + const submissions = useSubmissions("2022"); const { query, mutation } = useFormioSubmissionQueryAndMutation(rebateId); const { userAccess, formSchema, submission } = query.data ?? {}; @@ -160,7 +162,7 @@ function UserCloseOutForm(props: { email: string }) { */ const lastSuccesfullySubmittedData = useRef<{ [field: string]: unknown }>({}); - if (!csbData || !bapSamData) { + if (!configData || !bapSamData) { return ; } @@ -180,20 +182,21 @@ function UserCloseOutForm(props: { email: string }) { return ; } - const rebate = rebates.find((r) => r.rebateId === rebateId); + const rebate = submissions.find((r) => r.rebateId === rebateId); - const closeOutNeedsEdits = !rebate + const crfNeedsEdits = !rebate ? false : submissionNeedsEdits({ - formio: rebate.closeOut.formio, - bap: rebate.closeOut.bap, + formio: rebate.crf.formio, + bap: rebate.crf.bap, }); - const closeOutFormOpen = csbData.submissionPeriodOpen.closeOut; + const crfSubmissionPeriodOpen = + configData.submissionPeriodOpen[rebateYear].crf; const formIsReadOnly = - (submission.state === "submitted" || !closeOutFormOpen) && - !closeOutNeedsEdits; + (submission.state === "submitted" || !crfSubmissionPeriodOpen) && + !crfNeedsEdits; /** matched SAM.gov entity for the Close Out submission */ const entity = bapSamData.entities.find((entity) => { @@ -218,9 +221,9 @@ function UserCloseOutForm(props: { email: string }) { className="margin-top-4" children={ submission.state === "draft" - ? content.draftCloseOutIntro + ? content.draftCRFIntro : submission.state === "submitted" - ? content.submittedCloseOutIntro + ? content.submittedCRFIntro : "" } /> diff --git a/app/client/src/routes/applicationForm.tsx b/app/client/src/routes/frf2022.tsx similarity index 88% rename from app/client/src/routes/applicationForm.tsx rename to app/client/src/routes/frf2022.tsx index fd8b1e3c..8e41dcad 100644 --- a/app/client/src/routes/applicationForm.tsx +++ b/app/client/src/routes/frf2022.tsx @@ -9,14 +9,14 @@ import icons from "uswds/img/sprite.svg"; // --- import { serverUrl, messages } from "../config"; import { - FormioApplicationSubmission, + FormioFRF2022Submission, getData, postData, useContentData, - useCsbData, + useConfigData, useBapSamData, useSubmissionsQueries, - useRebates, + useSubmissions, submissionNeedsEdits, getUserInfo, } from "../utilities"; @@ -25,6 +25,7 @@ import { Message } from "components/message"; import { MarkdownContent } from "components/markdownContent"; import { useDialogActions } from "contexts/dialog"; import { useNotificationsActions } from "contexts/notifications"; +import { useRebateYearState } from "contexts/rebateYear"; type ServerResponse = | { @@ -35,7 +36,7 @@ type ServerResponse = | { userAccess: true; formSchema: { url: string; json: object }; - submission: FormioApplicationSubmission; + submission: FormioFRF2022Submission; }; /** Custom hook to fetch Formio submission data */ @@ -43,13 +44,13 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { const queryClient = useQueryClient(); useEffect(() => { - queryClient.resetQueries({ queryKey: ["application"] }); + queryClient.resetQueries({ queryKey: ["formio/2022/frf-submission"] }); }, [queryClient]); - const url = `${serverUrl}/api/formio-application-submission/${mongoId}`; + const url = `${serverUrl}/api/formio/2022/frf-submission/${mongoId}`; const query = useQuery({ - queryKey: ["application", { id: mongoId }], + queryKey: ["formio/2022/frf-submission", { id: mongoId }], queryFn: () => { return getData(url).then((res) => { const comboKey = res.submission?.data.bap_hidden_entity_combo_key; @@ -64,7 +65,7 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { */ Formio.Providers.providers.storage.s3 = function (formio: any) { const s3Formio = cloneDeep(formio); - s3Formio.formUrl = `${serverUrl}/api/s3/application/${mongoId}/${comboKey}`; + s3Formio.formUrl = `${serverUrl}/api/formio/2022/s3/frf/${mongoId}/${comboKey}`; return s3(s3Formio); }; @@ -88,11 +89,11 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { metadata: { [field: string]: unknown }; state: "submitted" | "draft"; }) => { - return postData(url, updatedSubmission); + return postData(url, updatedSubmission); }, onSuccess: (res) => { return queryClient.setQueryData( - ["application", { id: mongoId }], + ["formio/2022/frf-submission", { id: mongoId }], (prevData) => { return prevData?.submission ? { ...prevData, submission: res } @@ -105,22 +106,22 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { return { query, mutation }; } -export function ApplicationForm() { +export function FRF2022() { const { email } = useOutletContext<{ email: string }>(); /* ensure user verification (JWT refresh) doesn't cause form to re-render */ return useMemo(() => { - return ; + return ; }, [email]); } -function UserApplicationForm(props: { email: string }) { +function FundingRequestForm(props: { email: string }) { const { email } = props; const navigate = useNavigate(); const { id: mongoId } = useParams<"id">(); // MongoDB ObjectId string const content = useContentData(); - const csbData = useCsbData(); + const configData = useConfigData(); const bapSamData = useBapSamData(); const { displayDialog } = useDialogActions(); const { @@ -129,9 +130,10 @@ function UserApplicationForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); + const { rebateYear } = useRebateYearState(); - const submissionsQueries = useSubmissionsQueries(); - const rebates = useRebates(); + const submissionsQueries = useSubmissionsQueries("2022"); + const submissions = useSubmissions("2022"); const { query, mutation } = useFormioSubmissionQueryAndMutation(mongoId); const { userAccess, formSchema, submission } = query.data ?? {}; @@ -167,7 +169,7 @@ function UserApplicationForm(props: { email: string }) { */ const lastSuccesfullySubmittedData = useRef<{ [field: string]: unknown }>({}); - if (!csbData || !bapSamData) { + if (!configData || !bapSamData) { return ; } @@ -187,26 +189,24 @@ function UserApplicationForm(props: { email: string }) { return ; } - const rebate = rebates.find((r) => r.application.formio._id === mongoId); + const rebate = submissions.find((r) => r.frf.formio._id === mongoId); - const applicationNeedsEdits = !rebate + const frfNeedsEdits = !rebate ? false : submissionNeedsEdits({ - formio: rebate.application.formio, - bap: rebate.application.bap, + formio: rebate.frf.formio, + bap: rebate.frf.bap, }); - const applicationNeedsEditsAndPaymentRequestExists = - applicationNeedsEdits && !!rebate?.paymentRequest.formio; + const frfNeedsEditsAndPRFExists = frfNeedsEdits && !!rebate?.prf.formio; /** - * NOTE: If the Application form submission needs edits and there's a - * corresponding Payment Request form submission, display a confirmation - * dialog prompting the user to delete the Payment Request form submission, - * as it's data will no longer valid when the Application form submission's + * NOTE: If the FRF submission needs edits and there's a corresponding PRF + * submission, display a confirmation dialog prompting the user to delete the + * PRF submission, as it's data will no longer valid when the FRF submission's * data is changed. */ - if (applicationNeedsEditsAndPaymentRequestExists) { + if (frfNeedsEditsAndPRFExists) { displayDialog({ dismissable: true, heading: "Submission Edits Requested", @@ -248,9 +248,9 @@ function UserApplicationForm(props: { email: string }) { ), confirmText: "Delete Payment Request Form Submission", confirmedAction: () => { - const paymentRequest = rebate.paymentRequest.formio; + const prf = rebate.prf.formio; - if (!paymentRequest) { + if (!prf) { displayErrorNotification({ id: Date.now(), body: ( @@ -281,12 +281,12 @@ function UserApplicationForm(props: { email: string }) { ), }); - const url = `${serverUrl}/api/delete-formio-payment-request-submission`; + const url = `${serverUrl}/api/formio/2022/delete-prf-submission`; postData(url, { - mongoId: paymentRequest._id, - rebateId: paymentRequest.data.hidden_bap_rebate_id, - comboKey: paymentRequest.data.bap_hidden_entity_combo_key, + mongoId: prf._id, + rebateId: prf.data.hidden_bap_rebate_id, + comboKey: prf.data.bap_hidden_entity_combo_key, }) .then((res) => { window.location.reload(); @@ -308,17 +308,18 @@ function UserApplicationForm(props: { email: string }) { }); }); }, - dismissedAction: () => navigate(`/payment-request/${rebate.rebateId}`), + dismissedAction: () => navigate(`/prf/2022/${rebate.rebateId}`), }); return null; } - const applicationFormOpen = csbData.submissionPeriodOpen.application; + const frfSubmissionPeriodOpen = + configData.submissionPeriodOpen[rebateYear].frf; const formIsReadOnly = - (submission.state === "submitted" || !applicationFormOpen) && - !applicationNeedsEdits; + (submission.state === "submitted" || !frfSubmissionPeriodOpen) && + !frfNeedsEdits; /** matched SAM.gov entity for the Application submission */ const entity = bapSamData.entities.find((entity) => { @@ -343,9 +344,9 @@ function UserApplicationForm(props: { email: string }) { className="margin-top-4" children={ submission.state === "draft" - ? content.draftApplicationIntro + ? content.draftFRFIntro : submission.state === "submitted" - ? content.submittedApplicationIntro + ? content.submittedFRFIntro : "" } /> @@ -363,7 +364,7 @@ function UserApplicationForm(props: { email: string }) { - {rebate?.application.bap?.rebateId && ( + {rebate?.frf.bap?.rebateId && ( @@ -371,7 +372,7 @@ function UserApplicationForm(props: { email: string }) { - Rebate ID: {rebate.application.bap.rebateId} + Rebate ID: {rebate.frf.bap.rebateId} )} diff --git a/app/client/src/routes/frf2023.tsx b/app/client/src/routes/frf2023.tsx new file mode 100644 index 00000000..ef426de0 --- /dev/null +++ b/app/client/src/routes/frf2023.tsx @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query"; +import { Dialog } from "@headlessui/react"; +import { Formio, Form } from "@formio/react"; +import s3 from "formiojs/providers/storage/s3"; +import { cloneDeep, isEqual } from "lodash"; +import icons from "uswds/img/sprite.svg"; +// --- +import { serverUrl, messages } from "../config"; +import { + FormioFRF2023Submission, + getData, + postData, + useContentData, + useConfigData, + useBapSamData, + useSubmissionsQueries, + // useSubmissions, + // submissionNeedsEdits, + getUserInfo, +} from "../utilities"; +import { Loading } from "components/loading"; +import { Message } from "components/message"; +import { MarkdownContent } from "components/markdownContent"; +import { useNotificationsActions } from "contexts/notifications"; +import { useRebateYearState } from "contexts/rebateYear"; + +type ServerResponse = + | { + userAccess: false; + formSchema: null; + submission: null; + } + | { + userAccess: true; + formSchema: { url: string; json: object }; + submission: FormioFRF2023Submission; + }; + +/** Custom hook to fetch Formio submission data */ +function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.resetQueries({ queryKey: ["formio/2023/frf-submission"] }); + }, [queryClient]); + + const url = `${serverUrl}/api/formio/2023/frf-submission/${mongoId}`; + + const query = useQuery({ + queryKey: ["formio/2023/frf-submission", { id: mongoId }], + queryFn: () => { + return getData(url).then((res) => { + const comboKey = res.submission?.data._bap_entity_combo_key; + + /** + * Change the formUrl the File component's `uploadFile` uses, so the s3 + * upload PUT request is routed through the server app. + * + * https://github.com/formio/formio.js/blob/master/src/components/file/File.js#L760 + * https://github.com/formio/formio.js/blob/master/src/providers/storage/s3.js#L5 + * https://github.com/formio/formio.js/blob/master/src/providers/storage/xhr.js#L90 + */ + Formio.Providers.providers.storage.s3 = function (formio: any) { + const s3Formio = cloneDeep(formio); + s3Formio.formUrl = `${serverUrl}/api/formio/2023/s3/frf/${mongoId}/${comboKey}`; + return s3(s3Formio); + }; + + return Promise.resolve(res); + }); + }, + refetchOnWindowFocus: false, + }); + + const mutation = useMutation({ + mutationFn: (updatedSubmission: { + data: { [field: string]: unknown }; + metadata: { [field: string]: unknown }; + state: "submitted" | "draft"; + }) => { + return postData(url, updatedSubmission); + }, + onSuccess: (res) => { + return queryClient.setQueryData( + ["formio/2023/frf-submission", { id: mongoId }], + (prevData) => { + return prevData?.submission + ? { ...prevData, submission: res } + : prevData; + } + ); + }, + }); + + return { query, mutation }; +} + +export function FRF2023() { + const { email } = useOutletContext<{ email: string }>(); + /* ensure user verification (JWT refresh) doesn't cause form to re-render */ + return useMemo(() => { + return ; + }, [email]); +} + +function FundingRequestForm(props: { email: string }) { + const { email } = props; + + const navigate = useNavigate(); + const { id: mongoId } = useParams<"id">(); // MongoDB ObjectId string + + const content = useContentData(); + const configData = useConfigData(); + const bapSamData = useBapSamData(); + const { + displaySuccessNotification, + displayErrorNotification, + dismissNotification, + } = useNotificationsActions(); + const { rebateYear } = useRebateYearState(); + + const submissionsQueries = useSubmissionsQueries("2023"); + // const submissions = useSubmissions("2023"); + + const { query, mutation } = useFormioSubmissionQueryAndMutation(mongoId); + const { userAccess, formSchema, submission } = query.data ?? {}; + + /** + * Stores when data is being posted to the server, so a loading overlay can + * be rendered over the form, preventing the user from losing input data when + * the form is re-rendered with data returned from the server's successful + * post response. + */ + const dataIsPosting = useRef(false); + + /** + * Stores when the form is being submitted, so it can be referenced in the + * Form component's `onSubmit` event prop to prevent double submits. + */ + const formIsBeingSubmitted = useRef(false); + + /** + * Stores the form data's state right after the user clicks the Save, Submit, + * or Next button. As soon as a post request to update the data succeeds, this + * pending submission data is reset to an empty object. This pending data, + * along with the submission data returned from the server is passed into the + * Form component's `submission` prop. + */ + const pendingSubmissionData = useRef<{ [field: string]: unknown }>({}); + + /** + * Stores the last succesfully submitted data, so it can be used in the Form + * component's `onNextPage` event prop's "dirty check" which determines if + * posting of updated data is needed (so we don't make needless requests if no + * field data in the form has changed). + */ + const lastSuccesfullySubmittedData = useRef<{ [field: string]: unknown }>({}); + + if (!configData || !bapSamData) { + return ; + } + + if (submissionsQueries.some((query) => query.isFetching)) { + return ; + } + + if (submissionsQueries.some((query) => query.isError)) { + return ; + } + + if (query.isInitialLoading) { + return ; + } + + if (query.isError || !userAccess || !formSchema || !submission) { + return ; + } + + // const rebate = submissions.find((r) => r.frf.formio._id === mongoId); + + // const frfNeedsEdits = !rebate + // ? false + // : submissionNeedsEdits({ + // formio: rebate.frf.formio, + // bap: rebate.frf.bap, + // }); + + const frfNeedsEdits = false; // TODO: update when BAP FRF work is completed + + const frfSubmissionPeriodOpen = + configData.submissionPeriodOpen[rebateYear].frf; + + const formIsReadOnly = + (submission.state === "submitted" || !frfSubmissionPeriodOpen) && + !frfNeedsEdits; + + /** matched SAM.gov entity for the Application submission */ + const entity = bapSamData.entities.find((entity) => { + const { ENTITY_COMBO_KEY__c } = entity; + return ENTITY_COMBO_KEY__c === submission.data._bap_entity_combo_key; + }); + + if (!entity) { + return ; + } + + if (entity.ENTITY_STATUS__c !== "Active") { + return ; + } + + const { title, name } = getUserInfo(email, entity); + + return ( + + {content && ( + + )} + + + + + + + + + + Application ID: {submission._id} + + + + + {}}> + + + + + + + + + + + + { + if (formIsReadOnly) return; + + // account for when form is being submitted to prevent double submits + if (formIsBeingSubmitted.current) return; + if (onSubmitSubmission.state === "submitted") { + formIsBeingSubmitted.current = true; + } + + const data = { ...onSubmitSubmission.data }; + + const updatedSubmission = { + ...onSubmitSubmission, + data, + }; + + dismissNotification({ id: 0 }); + dataIsPosting.current = true; + pendingSubmissionData.current = data; + + mutation.mutate(updatedSubmission, { + onSuccess: (res, payload, context) => { + pendingSubmissionData.current = {}; + lastSuccesfullySubmittedData.current = cloneDeep(res.data); + + /** success notification id */ + const id = Date.now(); + + displaySuccessNotification({ + id, + body: ( + + {onSubmitSubmission.state === "submitted" ? ( + <> + Application {mongoId} submitted successfully. + > + ) : ( + <>Draft saved successfully.> + )} + + ), + }); + + if (onSubmitSubmission.state === "submitted") { + /** + * NOTE: we'll keep the success notification displayed and + * redirect the user to their dashboard + */ + navigate("/"); + } + + if (onSubmitSubmission.state === "draft") { + setTimeout(() => dismissNotification({ id }), 5000); + } + }, + onError: (error, payload, context) => { + displayErrorNotification({ + id: Date.now(), + body: ( + + {onSubmitSubmission.state === "submitted" ? ( + <>Error submitting Application form.> + ) : ( + <>Error saving draft.> + )} + + ), + }); + }, + onSettled: (data, error, payload, context) => { + dataIsPosting.current = false; + formIsBeingSubmitted.current = false; + }, + }); + }} + onNextPage={(onNextPageParam: { + page: number; + submission: { + data: { [field: string]: unknown }; + metadata: { [field: string]: unknown }; + }; + }) => { + if (formIsReadOnly) return; + + const data = { ...onNextPageParam.submission.data }; + + // "dirty check" – don't post an update if no changes have been made + // to the form (ignoring current user fields) + const currentData = { ...data }; + const submittedData = { ...lastSuccesfullySubmittedData.current }; + delete currentData._user_email; + delete currentData._user_title; + delete currentData._user_name; + delete submittedData._user_email; + delete submittedData._user_title; + delete submittedData._user_name; + if (isEqual(currentData, submittedData)) return; + + const updatedSubmission = { + ...onNextPageParam.submission, + data, + state: "draft" as const, + }; + + dismissNotification({ id: 0 }); + dataIsPosting.current = true; + pendingSubmissionData.current = data; + + mutation.mutate(updatedSubmission, { + onSuccess: (res, payload, context) => { + pendingSubmissionData.current = {}; + lastSuccesfullySubmittedData.current = cloneDeep(res.data); + + /** success notification id */ + const id = Date.now(); + + displaySuccessNotification({ + id, + body: ( + + Draft saved successfully. + + ), + }); + + setTimeout(() => dismissNotification({ id }), 5000); + }, + onError: (error, payload, context) => { + displayErrorNotification({ + id: Date.now(), + body: ( + + Error saving draft. + + ), + }); + }, + onSettled: (data, error, payload, context) => { + dataIsPosting.current = false; + }, + }); + }} + /> + + + ); +} diff --git a/app/client/src/routes/newApplicationForm.tsx b/app/client/src/routes/frfNew.tsx similarity index 74% rename from app/client/src/routes/newApplicationForm.tsx rename to app/client/src/routes/frfNew.tsx index 58a248e0..48032f8d 100644 --- a/app/client/src/routes/newApplicationForm.tsx +++ b/app/client/src/routes/frfNew.tsx @@ -6,10 +6,12 @@ import icons from "uswds/img/sprite.svg"; // --- import { serverUrl, messages } from "../config"; import { - FormioApplicationSubmission, + BapSamEntity, + FormioFRF2022Submission, + FormioFRF2023Submission, postData, useContentData, - useCsbData, + useConfigData, useBapSamData, getUserInfo, } from "../utilities"; @@ -17,14 +19,77 @@ import { Loading, LoadingButtonIcon } from "components/loading"; import { Message } from "components/message"; import { MarkdownContent } from "components/markdownContent"; import { TextWithTooltip } from "components/tooltip"; +import { useRebateYearState } from "contexts/rebateYear"; -export function NewApplicationForm() { +/** + * Creates the initial FRF submission data for a given rebate year + */ +function createInitialSubmissionData(options: { + rebateYear: "2022" | "2023"; + email: string; + entity: BapSamEntity; +}) { + const { rebateYear, email, entity } = options; + + const { title, name } = getUserInfo(email, entity); + const comboKey = entity.ENTITY_COMBO_KEY__c; + const uei = entity.UNIQUE_ENTITY_ID__c; + const efti = entity.ENTITY_EFT_INDICATOR__c; + const orgName = entity.LEGAL_BUSINESS_NAME__c; + const address1 = entity.PHYSICAL_ADDRESS_LINE_1__c; + const address2 = entity.PHYSICAL_ADDRESS_LINE_2__c; + const city = entity.PHYSICAL_ADDRESS_CITY__c; + const state = entity.PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c; + const zip = entity.PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c; + + return rebateYear === "2022" + ? { + last_updated_by: email, + hidden_current_user_email: email, + hidden_current_user_title: title, + hidden_current_user_name: name, + bap_hidden_entity_combo_key: comboKey, + sam_hidden_applicant_email: email, + sam_hidden_applicant_title: title, + sam_hidden_applicant_name: name, + sam_hidden_applicant_efti: efti, + sam_hidden_applicant_uei: uei, + sam_hidden_applicant_organization_name: orgName, + sam_hidden_applicant_street_address_1: address1, + sam_hidden_applicant_street_address_2: address2, + sam_hidden_applicant_city: city, + sam_hidden_applicant_state: state, + sam_hidden_applicant_zip_code: zip, + } + : rebateYear === "2023" + ? { + _user_email: email, + _user_title: title, + _user_name: name, + _bap_entity_combo_key: comboKey, + _bap_applicant_email: email, + _bap_applicant_title: title, + _bap_applicant_name: name, + _bap_applicant_efti: efti, + _bap_applicant_uei: uei, + _bap_applicant_organization_name: orgName, + _bap_applicant_street_address_1: address1, + _bap_applicant_street_address_2: address2, + _bap_applicant_city: city, + _bap_applicant_state: state, + _bap_applicant_zip: zip, + } + : null; +} + +export function FRFNew() { const navigate = useNavigate(); const { email } = useOutletContext<{ email: string }>(); const content = useContentData(); - const csbData = useCsbData(); + const configData = useConfigData(); const bapSamData = useBapSamData(); + const { rebateYear } = useRebateYearState(); const [errorMessage, setErrorMessage] = useState<{ displayed: boolean; @@ -37,15 +102,16 @@ export function NewApplicationForm() { /** * Stores when data is being posted to the server, so a loading indicator can * be rendered inside the new application button, and we can prevent double - * submits/creations of new Application form submissions. + * submits/creations of new FRF submissions. */ const [postingDataId, setPostingDataId] = useState("0"); - if (!csbData || !bapSamData) { + if (!configData || !bapSamData) { return ; } - const applicationFormOpen = csbData.submissionPeriodOpen.application; + const frfSubmissionPeriodOpen = + configData.submissionPeriodOpen[rebateYear].frf; const activeSamEntities = bapSamData.entities.filter((entity) => { return entity.ENTITY_STATUS__c === "Active"; @@ -83,7 +149,7 @@ export function NewApplicationForm() { > - + - {!applicationFormOpen ? ( + {!frfSubmissionPeriodOpen ? ( - + ) : activeSamEntities.length <= 0 ? ( @@ -115,7 +178,7 @@ export function NewApplicationForm() { {content && ( ( @@ -188,37 +251,23 @@ export function NewApplicationForm() { if (postingDataId !== "0") return; setPostingDataId(comboKey); - const { title, name } = getUserInfo( - email, - entity - ); + const data = + createInitialSubmissionData({ + rebateYear, + email, + entity, + }); - postData( - `${serverUrl}/api/formio-application-submission/`, - { - data: { - last_updated_by: email, - hidden_current_user_email: email, - hidden_current_user_title: title, - hidden_current_user_name: name, - bap_hidden_entity_combo_key: comboKey, // prettier-ignore - sam_hidden_applicant_email: email, - sam_hidden_applicant_title: title, - sam_hidden_applicant_name: name, - sam_hidden_applicant_efti: efti, - sam_hidden_applicant_uei: uei, - sam_hidden_applicant_organization_name: orgName, // prettier-ignore - sam_hidden_applicant_street_address_1: entity.PHYSICAL_ADDRESS_LINE_1__c, // prettier-ignore - sam_hidden_applicant_street_address_2: entity.PHYSICAL_ADDRESS_LINE_2__c, // prettier-ignore - sam_hidden_applicant_city: entity.PHYSICAL_ADDRESS_CITY__c, // prettier-ignore - sam_hidden_applicant_state: entity.PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c, // prettier-ignore - sam_hidden_applicant_zip_code: entity.PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c, // prettier-ignore - }, - state: "draft", - } + postData< + | FormioFRF2022Submission + | FormioFRF2023Submission + >( + `${serverUrl}/api/formio/${rebateYear}/frf-submission/`, + { data, state: "draft" } ) .then((res) => { - navigate(`/rebate/${res._id}`); + const url = `/frf/${rebateYear}/${res._id}`; + navigate(url); }) .catch((err) => { setErrorMessage({ diff --git a/app/client/src/routes/helpdesk.tsx b/app/client/src/routes/helpdesk.tsx index 7fb47e80..f5d3fcf4 100644 --- a/app/client/src/routes/helpdesk.tsx +++ b/app/client/src/routes/helpdesk.tsx @@ -9,14 +9,14 @@ import { serverUrl, messages, formioStatusMap, - bapApplicationStatusMap, - bapPaymentRequestStatusMap, - bapCloseOutStatusMap, + bapFRFStatusMap, + bapPRFStatusMap, + bapCRFStatusMap, } from "../config"; import { - FormioApplicationSubmission, - FormioPaymentRequestSubmission, - FormioCloseOutSubmission, + FormioFRF2022Submission, + FormioPRF2022Submission, + FormioCRF2022Submission, BapSubmission, getData, postData, @@ -29,7 +29,7 @@ import { Message } from "components/message"; import { MarkdownContent } from "components/markdownContent"; import { TextWithTooltip } from "components/tooltip"; -type FormType = "application" | "payment-request" | "close-out"; +type FormType = "frf" | "prf" | "crf"; type ServerResponse = | { @@ -40,9 +40,9 @@ type ServerResponse = | { formSchema: { url: string; json: object }; formio: - | FormioApplicationSubmission - | FormioPaymentRequestSubmission - | FormioCloseOutSubmission; + | FormioFRF2022Submission + | FormioPRF2022Submission + | FormioCRF2022Submission; bap: BapSubmission; }; @@ -62,9 +62,9 @@ function formatTime(dateTimeString: string | null) { function getStatus(options: { formType: FormType; formio: - | FormioApplicationSubmission - | FormioPaymentRequestSubmission - | FormioCloseOutSubmission; + | FormioFRF2022Submission + | FormioPRF2022Submission + | FormioCRF2022Submission; bap: BapSubmission; }) { const { formType, formio, bap } = options; @@ -75,12 +75,12 @@ function getStatus(options: { return submissionNeedsEdits({ formio, bap }) ? "Edits Requested" - : formType === "application" - ? bapApplicationStatusMap.get(bapInternalStatus) || formioStatus - : formType === "payment-request" - ? bapPaymentRequestStatusMap.get(bapInternalStatus) || formioStatus - : formType === "close-out" - ? bapCloseOutStatusMap.get(bapInternalStatus) || formioStatus + : formType === "frf" + ? bapFRFStatusMap.get(bapInternalStatus) || formioStatus + : formType === "prf" + ? bapPRFStatusMap.get(bapInternalStatus) || formioStatus + : formType === "crf" + ? bapCRFStatusMap.get(bapInternalStatus) || formioStatus : ""; } @@ -91,7 +91,7 @@ export function Helpdesk() { const content = useContentData(); const helpdeskAccess = useHelpdeskAccess(); - const [formType, setFormType] = useState("application"); + const [formType, setFormType] = useState("frf"); const [searchText, setSearchText] = useState(""); const [lastSearchedText, setLastSearchedText] = useState(""); const [resultDisplayed, setResultDisplayed] = useState(false); @@ -101,7 +101,7 @@ export function Helpdesk() { queryClient.resetQueries({ queryKey: ["helpdesk"] }); }, [queryClient]); - const url = `${serverUrl}/help/formio-submission/${formType}/${searchText}`; + const url = `${serverUrl}/api/help/formio/submission/${formType}/${searchText}`; const query = useQuery({ queryKey: ["helpdesk"], @@ -138,12 +138,12 @@ export function Helpdesk() { { setFormType(ev.target.value as FormType); setResultDisplayed(false); @@ -152,7 +152,7 @@ export function Helpdesk() { /> Application @@ -160,12 +160,12 @@ export function Helpdesk() { { setFormType(ev.target.value as FormType); setResultDisplayed(false); @@ -174,7 +174,7 @@ export function Helpdesk() { /> Payment Request @@ -182,12 +182,12 @@ export function Helpdesk() { { setFormType(ev.target.value as FormType); setResultDisplayed(false); @@ -196,7 +196,7 @@ export function Helpdesk() { /> Close Out @@ -324,21 +324,21 @@ export function Helpdesk() { {getStatus({ formType, formio, bap })} - {formType === "application" ? ( + {formType === "frf" ? ( {formio.data.sam_hidden_applicant_name as string} - ) : formType === "payment-request" ? ( + ) : formType === "prf" ? ( {formio.data.applicantName as string} - ) : formType === "close-out" ? ( + ) : formType === "crf" ? ( {formio.data.signatureName as string} ) : ( )} - {formType === "application" ? ( + {formType === "frf" ? ( {formio.data.last_updated_by as string} - ) : formType === "payment-request" ? ( + ) : formType === "prf" ? ( {formio.data.hidden_current_user_email as string} - ) : formType === "close-out" ? ( + ) : formType === "crf" ? ( {formio.data.hidden_current_user_email as string} ) : ( diff --git a/app/client/src/routes/paymentRequestForm.tsx b/app/client/src/routes/prf2022.tsx similarity index 83% rename from app/client/src/routes/paymentRequestForm.tsx rename to app/client/src/routes/prf2022.tsx index d2bbdefd..8644cf0c 100644 --- a/app/client/src/routes/paymentRequestForm.tsx +++ b/app/client/src/routes/prf2022.tsx @@ -9,14 +9,14 @@ import icons from "uswds/img/sprite.svg"; // --- import { serverUrl, messages } from "../config"; import { - FormioPaymentRequestSubmission, + FormioPRF2022Submission, getData, postData, useContentData, - useCsbData, + useConfigData, useBapSamData, useSubmissionsQueries, - useRebates, + useSubmissions, submissionNeedsEdits, getUserInfo, } from "../utilities"; @@ -24,6 +24,7 @@ import { Loading } from "components/loading"; import { Message } from "components/message"; import { MarkdownContent } from "components/markdownContent"; import { useNotificationsActions } from "contexts/notifications"; +import { useRebateYearState } from "contexts/rebateYear"; type ServerResponse = | { @@ -34,7 +35,7 @@ type ServerResponse = | { userAccess: true; formSchema: { url: string; json: object }; - submission: FormioPaymentRequestSubmission; + submission: FormioPRF2022Submission; }; /** Custom hook to fetch Formio submission data */ @@ -42,13 +43,13 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const queryClient = useQueryClient(); useEffect(() => { - queryClient.resetQueries({ queryKey: ["payment-request"] }); + queryClient.resetQueries({ queryKey: ["formio/2022/prf-submission"] }); }, [queryClient]); - const url = `${serverUrl}/api/formio-payment-request-submission/${rebateId}`; + const url = `${serverUrl}/api/formio/2022/prf-submission/${rebateId}`; const query = useQuery({ - queryKey: ["payment-request", { id: rebateId }], + queryKey: ["formio/2022/prf-submission", { id: rebateId }], queryFn: () => { return getData(url).then((res) => { const mongoId = res.submission?._id; @@ -64,7 +65,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { */ Formio.Providers.providers.storage.s3 = function (formio: any) { const s3Formio = cloneDeep(formio); - s3Formio.formUrl = `${serverUrl}/api/s3/payment-request/${mongoId}/${comboKey}`; + s3Formio.formUrl = `${serverUrl}/api/formio/2022/s3/prf/${mongoId}/${comboKey}`; return s3(s3Formio); }; @@ -83,11 +84,11 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { state: "submitted" | "draft"; }; }) => { - return postData(url, updatedSubmission); + return postData(url, updatedSubmission); }, onSuccess: (res) => { return queryClient.setQueryData( - ["payment-request", { id: rebateId }], + ["formio/2022/prf-submission", { id: rebateId }], (prevData) => { return prevData?.submission ? { ...prevData, submission: res } @@ -100,31 +101,32 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { return { query, mutation }; } -export function PaymentRequestForm() { +export function PRF2022() { const { email } = useOutletContext<{ email: string }>(); /* ensure user verification (JWT refresh) doesn't cause form to re-render */ return useMemo(() => { - return ; + return ; }, [email]); } -function UserPaymentRequestForm(props: { email: string }) { +function PaymentRequestForm(props: { email: string }) { const { email } = props; const navigate = useNavigate(); const { id: rebateId } = useParams<"id">(); // CSB Rebate ID (6 digits) const content = useContentData(); - const csbData = useCsbData(); + const configData = useConfigData(); const bapSamData = useBapSamData(); const { displaySuccessNotification, displayErrorNotification, dismissNotification, } = useNotificationsActions(); + const { rebateYear } = useRebateYearState(); - const submissionsQueries = useSubmissionsQueries(); - const rebates = useRebates(); + const submissionsQueries = useSubmissionsQueries("2022"); + const submissions = useSubmissions("2022"); const { query, mutation } = useFormioSubmissionQueryAndMutation(rebateId); const { userAccess, formSchema, submission } = query.data ?? {}; @@ -160,7 +162,7 @@ function UserPaymentRequestForm(props: { email: string }) { */ const lastSuccesfullySubmittedData = useRef<{ [field: string]: unknown }>({}); - if (!csbData || !bapSamData) { + if (!configData || !bapSamData) { return ; } @@ -180,28 +182,29 @@ function UserPaymentRequestForm(props: { email: string }) { return ; } - const rebate = rebates.find((r) => r.rebateId === rebateId); + const rebate = submissions.find((r) => r.rebateId === rebateId); - const applicationNeedsEdits = !rebate + const frfNeedsEdits = !rebate ? false : submissionNeedsEdits({ - formio: rebate.application.formio, - bap: rebate.application.bap, + formio: rebate.frf.formio, + bap: rebate.frf.bap, }); - const paymentRequestNeedsEdits = !rebate + const prfNeedsEdits = !rebate ? false : submissionNeedsEdits({ - formio: rebate.paymentRequest.formio, - bap: rebate.paymentRequest.bap, + formio: rebate.prf.formio, + bap: rebate.prf.bap, }); - const paymentRequestFormOpen = csbData.submissionPeriodOpen.paymentRequest; + const prfSubmissionPeriodOpen = + configData.submissionPeriodOpen[rebateYear].prf; const formIsReadOnly = - applicationNeedsEdits || - ((submission.state === "submitted" || !paymentRequestFormOpen) && - !paymentRequestNeedsEdits); + frfNeedsEdits || + ((submission.state === "submitted" || !prfSubmissionPeriodOpen) && + !prfNeedsEdits); /** matched SAM.gov entity for the Payment Request submission */ const entity = bapSamData.entities.find((entity) => { @@ -235,19 +238,16 @@ function UserPaymentRequestForm(props: { email: string }) { className="margin-top-4" children={ submission.state === "draft" - ? content.draftPaymentRequestIntro + ? content.draftPRFIntro : submission.state === "submitted" - ? content.submittedPaymentRequestIntro + ? content.submittedPRFIntro : "" } /> )} - {applicationNeedsEdits && ( - + {frfNeedsEdits && ( + )} @@ -396,6 +396,29 @@ function UserPaymentRequestForm(props: { email: string }) { // to the form (ignoring current user fields) const currentData = { ...data }; const submittedData = { ...lastSuccesfullySubmittedData.current }; + + /** + * NOTE: if a user hasn't yet filled out the bus info fields, the + * `newBusDeliveryDate` field is returned as null from Formio, but + * is converted to an empty string when rendered by the Formio Form + * component, so we need to account for that in the dirty check by + * omitting that field from being checked. + */ + type BusInfo = Record[] | undefined; + + const currentDataBusInfo = currentData?.busInfo as BusInfo; + const submittedDataBusInfo = submittedData?.busInfo as BusInfo; + + currentDataBusInfo?.forEach((currentDataBusFields, index) => { + if ( + currentDataBusFields?.newBusDeliveryDate === "" && + submittedDataBusInfo?.[index]?.newBusDeliveryDate === null + ) { + delete currentDataBusFields.newBusDeliveryDate; + delete submittedDataBusInfo[index].newBusDeliveryDate; + } + }); + delete currentData.hidden_current_user_email; delete currentData.hidden_current_user_title; delete currentData.hidden_current_user_name; @@ -454,11 +477,8 @@ function UserPaymentRequestForm(props: { email: string }) { /> - {applicationNeedsEdits && ( - + {frfNeedsEdits && ( + )} ); diff --git a/app/client/src/routes/submissions.tsx b/app/client/src/routes/submissions.tsx new file mode 100644 index 00000000..323cd932 --- /dev/null +++ b/app/client/src/routes/submissions.tsx @@ -0,0 +1,1235 @@ +import { Fragment, useState } from "react"; +import type { LinkProps } from "react-router-dom"; +import { Link, useNavigate, useOutletContext } from "react-router-dom"; +import icons from "uswds/img/sprite.svg"; +// --- +import { serverUrl, messages } from "../config"; +import { + Rebate, + postData, + useContentData, + useConfigData, + useBapSamData, + useSubmissionsQueries, + useSubmissions, + submissionNeedsEdits, + getUserInfo, +} from "../utilities"; +import { Loading, LoadingButtonIcon } from "components/loading"; +import { Message } from "components/message"; +import { MarkdownContent } from "components/markdownContent"; +import { TextWithTooltip } from "components/tooltip"; +import { useNotificationsActions } from "contexts/notifications"; +import { + RebateYear, + useRebateYearState, + useRebateYearActions, +} from "contexts/rebateYear"; + +const defaultTableRowClassNames = "bg-gray-5"; +const highlightedTableRowClassNames = "bg-primary-lighter"; + +function ButtonLink(props: { type: "edit" | "view"; to: LinkProps["to"] }) { + const icon = props.type === "edit" ? "edit" : "visibility"; + const text = props.type === "edit" ? "Edit" : "View"; + const linkClassNames = + `usa-button ` + + `${props.type === "view" ? "usa-button--base " : ""}` + + `font-sans-2xs margin-right-0 padding-x-105 padding-y-1`; + + return ( + + + + + + {text} + + + ); +} + +function NewApplicationIconText() { + return ( + + + + + New Application + + ); +} + +function SubmissionsTableHeader() { + return ( + + + + Open + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function FRF2022Submission(props: { + rebate: Extract; +}) { + const { rebate } = props; + const { frf, prf, crf } = rebate; + + const configData = useConfigData(); + + if (!configData) return null; + + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].frf; + + const { + applicantUEI, + applicantEfti, + applicantEfti_display, + applicantOrganizationName, + schoolDistrictName, + last_updated_by, + } = frf.formio.data; + + const date = new Date(frf.formio.modified).toLocaleDateString(); + const time = new Date(frf.formio.modified).toLocaleTimeString(); + + const frfNeedsEdits = submissionNeedsEdits({ + formio: frf.formio, + bap: frf.bap, + }); + + const frfNeedsClarification = frf.bap?.status === "Needs Clarification"; + + const frfHasBeenWithdrawn = frf.bap?.status === "Withdrawn"; + + const frfNotSelected = frf.bap?.status === "Coordinator Denied"; + + const frfSelected = frf.bap?.status === "Accepted"; + + const frfSelectedButNoPRF = frfSelected && !Boolean(prf.formio); + + const prfFundingApproved = prf.bap?.status === "Accepted"; + + const prfFundingApprovedButNoCRF = prfFundingApproved && !Boolean(crf.formio); + + const statusTableCellClassNames = + frf.formio.state === "submitted" || !frfSubmissionPeriodOpen + ? "text-italic" + : ""; + + const statusIconClassNames = frfSelected + ? "usa-icon text-primary" // blue + : "usa-icon"; + + const statusIcon = frfNeedsEdits + ? `${icons}#priority_high` // ! + : frfHasBeenWithdrawn + ? `${icons}#close` // ✕ + : frfNotSelected + ? `${icons}#cancel` // x inside a circle + : frfSelected + ? `${icons}#check_circle` // check inside a circle + : frf.formio.state === "draft" + ? `${icons}#more_horiz` // three horizontal dots + : frf.formio.state === "submitted" + ? `${icons}#check` // check + : `${icons}#remove`; // — (fallback, not used) + + const statusText = frfNeedsEdits + ? "Edits Requested" + : frfHasBeenWithdrawn + ? "Withdrawn" + : frfNotSelected + ? "Not Selected" + : frfSelected + ? "Selected" + : frf.formio.state === "draft" + ? "Draft" + : frf.formio.state === "submitted" + ? "Submitted" + : ""; // fallback, not used + + const frfUrl = `/frf/2022/${frf.formio._id}`; + + /** + * NOTE on the usage of `TextWithTooltip` below: + * When a form is first initially created, and the user has not yet clicked + * the "Next" or "Save" buttons, any fields that the Formio form definition + * sets automatically (based on hidden fields we inject on form creation) will + * not yet be part of the form submission data. As soon as the user clicks the + * "Next" or "Save" buttons the first time, those fields will be set and + * stored in the submission. Since we display some of those fields in the + * table below, we need to check if their values exist, and if they don't (for + * cases where the user has not yet advanced past the first screen of the + * form...which we believe is a bit of an edge case, as most users will likely + * do that after starting a new application), indicate to the user they need + * to first save the form for the fields to be displayed. + */ + return ( + + + {frfNeedsEdits ? ( + + ) : frf.formio.state === "submitted" || !frfSubmissionPeriodOpen ? ( + + ) : frf.formio.state === "draft" ? ( + + ) : null} + + + + {frf.bap?.rebateId ? ( + + {frf.bap.rebateId} + + ) : ( + + )} + + + + Application + + + {frfNeedsClarification ? ( + + ) : ( + <> + + + + {statusText} + > + )} + + + + + <> + {Boolean(applicantUEI) ? ( + applicantUEI + ) : ( + + )} + + { + /* NOTE: +Initial version of the application form definition included the `applicantEfti` +field, which is configured via the form definition (in Formio) to set its value +based on the value of the `sam_hidden_applicant_efti` field, which we inject on +initial form submission. That value comes from the BAP (SAM.gov data), which +could be an empty string. + +To handle the potentially empty string, the Formio form definition was updated +to include a new `applicantEfti_display` field that's configured in the form +definition to set it's value to the string '0000' if the `applicantEfti` field's +value is an empty string. This logic (again, built into the form definition) +works great for new form submissions that have taken place after the form +definition has been updated to include this `applicantEfti_display` field... */ + Boolean(applicantEfti_display) ? ( + applicantEfti_display + ) : /* NOTE: +...but we need to handle old/existing submissions that were submitted before the +form definition was updated to include the new `applicantEfti_display` field, +and where the user has already advanced past the first screen (e.g. they've hit +the "Next" or "Save" buttons at least once). + +At this point the form definition logic has already kicked in that sets the +`applicaitonEfti` field, but it's value _could_ be an empty string (it won't +necessairly be, but it could be). Since the `applicantEfti` field's value could +be an empty string (which is falsy in JavaScript), we need to check another +field's value that will also set at this point, and whose value will always be +truthy. We'll check the `applicantUEI` field's value, as it's value will always +be set for users that have advanced past the first screen (we could have just as +easily used another field, like the `applicantOrganizationName` field for the +same result). */ + Boolean(applicantUEI) ? ( + /* NOTE: +If the `applicantUEI` field's value is truthy, we know the user has advanced +past the first screen, so we'll render the value of the `applicantEfti` field, +and fall back to "0000", which will be used in cases where the `applicantEfti` +field's value is an empty string. */ + applicantEfti || "0000" + ) : ( + /* NOTE: +At this point in the conditional logic, we know the user has not advanced past +the first screen, so we'll render the tooltip, indicating the user must edit and +save the form for the EFT indicator to be displayed. */ + + ) + } + > + + + + <> + {Boolean(applicantOrganizationName) ? ( + applicantOrganizationName + ) : ( + + )} + + {Boolean(schoolDistrictName) ? ( + schoolDistrictName + ) : ( + + )} + > + + + + {last_updated_by} + + {date} + + + ); +} + +function PRF2022Submission(props: { + rebate: Extract; +}) { + const { rebate } = props; + const { frf, prf, crf } = rebate; + + const navigate = useNavigate(); + const { email } = useOutletContext<{ email: string }>(); + + const configData = useConfigData(); + const bapSamData = useBapSamData(); + const { displayErrorNotification } = useNotificationsActions(); + + /** + * Stores when data is being posted to the server, so a loading indicator can + * be rendered inside the "New Payment Request" button, and we can prevent + * double submits/creations of new PRF submissions. + */ + const [dataIsPosting, setDataIsPosting] = useState(false); + + if (!configData || !bapSamData) return null; + + const prfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].prf; + + const frfSelected = frf.bap?.status === "Accepted"; + + const frfSelectedButNoPRF = frfSelected && !Boolean(prf.formio); + + /** matched SAM.gov entity for the FRF submission */ + const entity = bapSamData.entities.find((entity) => { + return ( + entity.ENTITY_STATUS__c === "Active" && + entity.ENTITY_COMBO_KEY__c === frf.formio.data.bap_hidden_entity_combo_key + ); + }); + + if (frfSelectedButNoPRF) { + return ( + + + { + if (!frf.bap || !entity) return; + + // account for when data is posting to prevent double submits + if (dataIsPosting) return; + setDataIsPosting(true); + + const { title, name } = getUserInfo(email, entity); + + // create a new draft PRF submission + postData(`${serverUrl}/api/formio/2022/prf-submission/`, { + email, + title, + name, + entity, + comboKey: frf.bap.comboKey, + rebateId: frf.bap.rebateId, // CSB Rebate ID (6 digits) + frfReviewItemId: frf.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) + frfFormModified: frf.bap.modified, + }) + .then((res) => { + navigate(`/prf/2022/${frf.bap?.rebateId}`); + }) + .catch((err) => { + displayErrorNotification({ + id: Date.now(), + body: ( + <> + + Error creating Payment Request{" "} + {frf.bap?.rebateId}. + + + Please try again. + + > + ), + }); + }) + .finally(() => { + setDataIsPosting(false); + }); + }} + > + + + + + New Payment Request + {dataIsPosting && } + + + + + ); + } + + // return if a Payment Request submission has not been created for this rebate + if (!prf.formio) return null; + + const { hidden_current_user_email, hidden_bap_rebate_id } = prf.formio.data; + + const date = new Date(prf.formio.modified).toLocaleDateString(); + const time = new Date(prf.formio.modified).toLocaleTimeString(); + + const frfNeedsEdits = submissionNeedsEdits({ + formio: frf.formio, + bap: frf.bap, + }); + + const prfNeedsEdits = submissionNeedsEdits({ + formio: prf.formio, + bap: prf.bap, + }); + + const prfNeedsClarification = prf.bap?.status === "Needs Clarification"; + + const prfHasBeenWithdrawn = prf.bap?.status === "Withdrawn"; + + const prfFundingNotApproved = prf.bap?.status === "Coordinator Denied"; + + const prfFundingApproved = prf.bap?.status === "Accepted"; + + const prfFundingApprovedButNoCRF = prfFundingApproved && !Boolean(crf.formio); + + const statusTableCellClassNames = + prf.formio.state === "submitted" || !prfSubmissionPeriodOpen + ? "text-italic" + : ""; + + const statusIconClassNames = prfFundingApproved + ? "usa-icon text-primary" // blue + : "usa-icon"; + + const statusIcon = prfNeedsEdits + ? `${icons}#priority_high` // ! + : prfHasBeenWithdrawn + ? `${icons}#close` // ✕ + : prfFundingNotApproved + ? `${icons}#cancel` // ✕ inside a circle + : prfFundingApproved + ? `${icons}#check_circle` // check inside a circle + : prf.formio.state === "draft" + ? `${icons}#more_horiz` // three horizontal dots + : prf.formio.state === "submitted" + ? `${icons}#check` // check + : `${icons}#remove`; // — (fallback, not used) + + const statusText = prfNeedsEdits + ? "Edits Requested" + : prfHasBeenWithdrawn + ? "Withdrawn" + : prfFundingNotApproved + ? "Funding Not Approved" + : prfFundingApproved + ? "Funding Approved" + : prf.formio.state === "draft" + ? "Draft" + : prf.formio.state === "submitted" + ? "Submitted" + : ""; // fallback, not used + + const prfUrl = `/prf/2022/${hidden_bap_rebate_id}`; + + return ( + + + {frfNeedsEdits ? ( + + ) : prfNeedsEdits ? ( + + ) : prf.formio.state === "submitted" || !prfSubmissionPeriodOpen ? ( + + ) : prf.formio.state === "draft" ? ( + + ) : null} + + + + + + Payment Request + + + {prfNeedsClarification ? ( + + ) : ( + <> + + + + {statusText} + > + )} + + + + + + + + + {hidden_current_user_email} + + {date} + + + ); +} + +function CRF2022Submission(props: { + rebate: Extract; +}) { + const { rebate } = props; + const { frf, prf, crf } = rebate; + + const navigate = useNavigate(); + const { email } = useOutletContext<{ email: string }>(); + + const configData = useConfigData(); + const bapSamData = useBapSamData(); + const { displayErrorNotification } = useNotificationsActions(); + + /** + * Stores when data is being posted to the server, so a loading indicator can + * be rendered inside the "New Close Out" button, and we can prevent double + * submits/creations of new Close Out form submissions. + */ + const [dataIsPosting, setDataIsPosting] = useState(false); + + if (!configData || !bapSamData) return null; + + const crfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].crf; + + const prfFundingApproved = prf.bap?.status === "Accepted"; + + const prfFundingApprovedButNoCRF = prfFundingApproved && !Boolean(crf.formio); + + /** matched SAM.gov entity for the PRF submission */ + const entity = bapSamData.entities.find((entity) => { + return ( + entity.ENTITY_STATUS__c === "Active" && + entity.ENTITY_COMBO_KEY__c === + prf.formio?.data.bap_hidden_entity_combo_key + ); + }); + + if (prfFundingApprovedButNoCRF) { + return ( + + + { + if (!frf.bap || !prf.bap || !entity) return; + + // account for when data is posting to prevent double submits + if (dataIsPosting) return; + setDataIsPosting(true); + + const { title, name } = getUserInfo(email, entity); + + // create a new draft CRF submission + postData(`${serverUrl}/api/formio/2022/crf-submission/`, { + email, + title, + name, + entity, + comboKey: prf.bap.comboKey, + rebateId: prf.bap.rebateId, // CSB Rebate ID (6 digits) + frfReviewItemId: frf.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) + prfReviewItemId: prf.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) + prfModified: prf.bap.modified, + }) + .then((res) => { + navigate(`/crf/2022/${prf.bap?.rebateId}`); + }) + .catch((err) => { + displayErrorNotification({ + id: Date.now(), + body: ( + <> + + Error creating Close Out {prf.bap?.rebateId}. + + + Please try again. + + > + ), + }); + }) + .finally(() => { + setDataIsPosting(false); + }); + }} + > + + + + + New Close Out + {dataIsPosting && } + + + + + ); + } + + // return if a Close Out submission has not been created for this rebate + if (!crf.formio) return null; + + const { hidden_current_user_email, hidden_bap_rebate_id } = crf.formio.data; + + const date = new Date(crf.formio.modified).toLocaleDateString(); + const time = new Date(crf.formio.modified).toLocaleTimeString(); + + const crfNeedsEdits = submissionNeedsEdits({ + formio: crf.formio, + bap: crf.bap, + }); + + const crfNeedsClarification = crf.bap?.status === "Needs Clarification"; + + const crfReimbursementNeeded = crf.bap?.status === "Reimbursement Needed"; + + const crfNotApproved = crf.bap?.status === "Branch Director Denied"; + + const crfApproved = crf.bap?.status === "Branch Director Approved"; + + const statusTableCellClassNames = + crf.formio.state === "submitted" || !crfSubmissionPeriodOpen + ? "text-italic" + : ""; + + const statusIconClassNames = crfApproved + ? "usa-icon text-primary" // blue + : "usa-icon"; + + const statusIcon = crfNeedsEdits + ? `${icons}#priority_high` // ! + : crfNotApproved + ? `${icons}#cancel` // ✕ inside a circle + : crfApproved + ? `${icons}#check_circle` // check inside a circle + : crf.formio.state === "draft" + ? `${icons}#more_horiz` // three horizontal dots + : crf.formio.state === "submitted" + ? `${icons}#check` // check + : `${icons}#remove`; // — (fallback, not used) + + const statusText = crfNeedsEdits + ? "Edits Requested" + : crfNotApproved + ? "Close Out Not Approved" + : crfApproved + ? "Close Out Approved" + : crf.formio.state === "draft" + ? "Draft" + : crf.formio.state === "submitted" + ? "Submitted" + : ""; // fallback, not used + + const crfUrl = `/crf/2022/${hidden_bap_rebate_id}`; + + return ( + + + {crfNeedsEdits ? ( + + ) : crf.formio.state === "submitted" || !crfSubmissionPeriodOpen ? ( + + ) : crf.formio.state === "draft" ? ( + + ) : null} + + + + + + Close Out + + + {crfNeedsClarification ? ( + + ) : crfReimbursementNeeded ? ( + + ) : ( + <> + + + + {statusText} + > + )} + + + + + + + + + {hidden_current_user_email} + + {date} + + + ); +} + +function FRF2023Submission(props: { + rebate: Extract; +}) { + const { rebate } = props; + const { frf } = rebate; + + const configData = useConfigData(); + + if (!configData) return null; + + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].frf; + + const { + appInfo_uei, + appInfo_efti, + appInfo_orgName, + // TODO: (school district name field) + _user_email, + } = frf.formio.data; + + const appInfo_schoolDistrictName = null; // TODO: temporary + + const date = new Date(frf.formio.modified).toLocaleDateString(); + const time = new Date(frf.formio.modified).toLocaleTimeString(); + + const frfNeedsEdits = submissionNeedsEdits({ + formio: frf.formio, + bap: frf.bap, + }); + + /** + * NOTE: + * Setting of the statuses below will occur once the BAP's 2023 FRF work has + * been completed, so the code below will be updated in the future. + */ + + const frfNeedsClarification = false; // frf.bap?.status === "Needs Clarification"; + + const frfHasBeenWithdrawn = false; // frf.bap?.status === "Withdrawn"; + + const frfNotSelected = false; // frf.bap?.status === "Coordinator Denied"; + + const frfSelected = false; // frf.bap?.status === "Accepted"; + + const frfSelectedButNoPRF = false; // frfSelected && !Boolean(prf.formio); + + const prfFundingApproved = false; // prf.bap?.status === "Accepted"; + + const prfFundingApprovedButNoCRF = prfFundingApproved; // prfFundingApproved && !Boolean(crf.formio); + + const statusTableCellClassNames = + frf.formio.state === "submitted" || !frfSubmissionPeriodOpen + ? "text-italic" + : ""; + + const statusIconClassNames = frfSelected + ? "usa-icon text-primary" // blue + : "usa-icon"; + + const statusIcon = frfNeedsEdits + ? `${icons}#priority_high` // ! + : frfHasBeenWithdrawn + ? `${icons}#close` // ✕ + : frfNotSelected + ? `${icons}#cancel` // x inside a circle + : frfSelected + ? `${icons}#check_circle` // check inside a circle + : frf.formio.state === "draft" + ? `${icons}#more_horiz` // three horizontal dots + : frf.formio.state === "submitted" + ? `${icons}#check` // check + : `${icons}#remove`; // — (fallback, not used) + + const statusText = frfNeedsEdits + ? "Edits Requested" + : frfHasBeenWithdrawn + ? "Withdrawn" + : frfNotSelected + ? "Not Selected" + : frfSelected + ? "Selected" + : frf.formio.state === "draft" + ? "Draft" + : frf.formio.state === "submitted" + ? "Submitted" + : ""; // fallback, not used + + const frfUrl = `/frf/2023/${frf.formio._id}`; + + /** + * NOTE on the usage of `TextWithTooltip` below: + * When a form is first initially created, and the user has not yet clicked + * the "Next" or "Save" buttons, any fields that the Formio form definition + * sets automatically (based on hidden fields we inject on form creation) will + * not yet be part of the form submission data. As soon as the user clicks the + * "Next" or "Save" buttons the first time, those fields will be set and + * stored in the submission. Since we display some of those fields in the + * table below, we need to check if their values exist, and if they don't (for + * cases where the user has not yet advanced past the first screen of the + * form...which we believe is a bit of an edge case, as most users will likely + * do that after starting a new application), indicate to the user they need + * to first save the form for the fields to be displayed. + */ + return ( + + + {frfNeedsEdits ? ( + + ) : frf.formio.state === "submitted" || !frfSubmissionPeriodOpen ? ( + + ) : frf.formio.state === "draft" ? ( + + ) : null} + + + + + + + + Application + + + {frfNeedsClarification ? ( + + ) : ( + <> + + + + {statusText} + > + )} + + + + + <> + {Boolean(appInfo_uei) ? ( + appInfo_uei + ) : ( + + )} + + {Boolean(appInfo_efti) ? ( + appInfo_efti + ) : ( + + )} + > + + + + <> + {Boolean(appInfo_orgName) ? ( + appInfo_orgName + ) : ( + + )} + + {Boolean(appInfo_schoolDistrictName) ? ( + appInfo_schoolDistrictName + ) : ( + + )} + > + + + + {_user_email} + + {date} + + + ); +} + +function Submissions2022() { + const content = useContentData(); + const submissionsQueries = useSubmissionsQueries("2022"); + const submissions = useSubmissions("2022"); + + if (submissionsQueries.some((query) => query.isFetching)) { + return ; + } + + if (submissionsQueries.some((query) => query.isError)) { + return ; + } + + if (submissions.length === 0) { + return ( + + + + ); + } + + return ( + <> + {content && ( + + )} + + + + + + {submissions.map((rebate, index) => { + return rebate.rebateYear === "2022" ? ( + + + + + {/* blank row after all submissions but the last one */} + {index !== submissions.length - 1 && ( + + + + + + )} + + ) : null; + })} + + + + > + ); +} + +function Submissions2023() { + const content = useContentData(); + const submissionsQueries = useSubmissionsQueries("2023"); + const submissions = useSubmissions("2023"); + + if (submissionsQueries.some((query) => query.isFetching)) { + return ; + } + + if (submissionsQueries.some((query) => query.isError)) { + return ; + } + + if (submissions.length === 0) { + return ( + + + + ); + } + + return ( + <> + {content && ( + + )} + + + + + + {submissions.map((rebate, index) => { + return rebate.rebateYear === "2023" ? ( + + + {/* blank row after all submissions but the last one */} + {index !== submissions.length - 1 && ( + + + + + + )} + + ) : null; + })} + + + + > + ); +} + +export function Submissions() { + const content = useContentData(); + const configData = useConfigData(); + const bapSamData = useBapSamData(); + const { rebateYear } = useRebateYearState(); + const { setRebateYear } = useRebateYearActions(); + + const frfSubmissionPeriodOpen = configData + ? configData.submissionPeriodOpen[rebateYear].frf + : false; + + const btnClassNames = + "usa-button margin-0 padding-x-2 padding-y-1 width-full font-sans-2xs"; + + if (!bapSamData) return null; + + return ( + <> + {bapSamData.entities.some((e) => e.ENTITY_STATUS__c !== "Active") && ( + + )} + + + + + + Rebate Year: + + setRebateYear(ev.target.value as RebateYear)} + defaultValue={rebateYear} + > + 2022 + 2023 + + + + + {!frfSubmissionPeriodOpen ? ( + + + + ) : ( + + + + )} + + + + + {rebateYear === "2022" && } + {rebateYear === "2023" && } + + {content && ( + + )} + > + ); +} diff --git a/app/client/src/utilities.tsx b/app/client/src/utilities.tsx index 42826fe0..202a8d20 100644 --- a/app/client/src/utilities.tsx +++ b/app/client/src/utilities.tsx @@ -4,18 +4,20 @@ import { useSearchParams } from "react-router-dom"; // --- import { serverUrl, serverUrlForHrefs } from "./config"; +type RebateYear = "2022" | "2023"; + type Content = { siteAlert: string; helpdeskIntro: string; allRebatesIntro: string; allRebatesOutro: string; - newApplicationDialog: string; - draftApplicationIntro: string; - submittedApplicationIntro: string; - draftPaymentRequestIntro: string; - submittedPaymentRequestIntro: string; - draftCloseOutIntro: string; - submittedCloseOutIntro: string; + newFRFDialog: string; + draftFRFIntro: string; + submittedFRFIntro: string; + draftPRFIntro: string; + submittedPRFIntro: string; + draftCRFIntro: string; + submittedCRFIntro: string; }; type UserData = { @@ -24,15 +26,14 @@ type UserData = { exp: number; }; -export type CsbData = { +type ConfigData = { submissionPeriodOpen: { - application: boolean; - paymentRequest: boolean; - closeOut: boolean; + 2022: { frf: boolean; prf: boolean; crf: boolean }; + 2023: { frf: boolean; prf: boolean; crf: boolean }; }; }; -type BapSamEntity = { +export type BapSamEntity = { ENTITY_COMBO_KEY__c: string; UNIQUE_ENTITY_ID__c: string; ENTITY_EFT_INDICATOR__c: string; @@ -87,7 +88,7 @@ type BapFormSubmission = { attributes: { type: string; url: string }; }; -export type FormioApplicationSubmission = { +type FormioSubmission = { [field: string]: unknown; _id: string; // MongoDB ObjectId string state: "submitted" | "draft"; @@ -97,70 +98,96 @@ export type FormioApplicationSubmission = { }; data: { [field: string]: unknown; - // fields injected upon new draft Application submission creation: - last_updated_by: string; - hidden_current_user_email: string; - hidden_current_user_title: string; - hidden_current_user_name: string; - bap_hidden_entity_combo_key: string; - sam_hidden_applicant_email: string; - sam_hidden_applicant_title: string; - sam_hidden_applicant_name: string; - sam_hidden_applicant_efti: string; - sam_hidden_applicant_uei: string; - sam_hidden_applicant_organization_name: string; - sam_hidden_applicant_street_address_1: string; - sam_hidden_applicant_street_address_2: string; - sam_hidden_applicant_city: string; - sam_hidden_applicant_state: string; - sam_hidden_applicant_zip_code: string; - // fields set by form definition (among others): - applicantUEI: string; - applicantEfti: string; - applicantEfti_display: string; - applicantOrganizationName: string; - schoolDistrictName: string; }; }; -export type FormioPaymentRequestSubmission = { +type FormioFRF2022Data = { [field: string]: unknown; - _id: string; // MongoDB ObjectId string - state: "submitted" | "draft"; - modified: string; // ISO 8601 date time string - metadata: { - [field: string]: unknown; - }; - data: { - [field: string]: unknown; - // fields injected upon new draft Payment Request submission creation: - bap_hidden_entity_combo_key: string; - hidden_application_form_modified: string; // ISO 8601 date time string - hidden_current_user_email: string; - hidden_current_user_title: string; - hidden_current_user_name: string; - hidden_bap_rebate_id: string; - }; + // fields injected upon a new draft FRF submission creation: + last_updated_by: string; + hidden_current_user_email: string; + hidden_current_user_title: string; + hidden_current_user_name: string; + bap_hidden_entity_combo_key: string; + sam_hidden_applicant_email: string; + sam_hidden_applicant_title: string; + sam_hidden_applicant_name: string; + sam_hidden_applicant_efti: string; + sam_hidden_applicant_uei: string; + sam_hidden_applicant_organization_name: string; + sam_hidden_applicant_street_address_1: string; + sam_hidden_applicant_street_address_2: string; + sam_hidden_applicant_city: string; + sam_hidden_applicant_state: string; + sam_hidden_applicant_zip_code: string; + // fields set by form definition (among others): + applicantUEI: string; + applicantEfti: string; + applicantEfti_display: string; + applicantOrganizationName: string; + schoolDistrictName: string; }; -export type FormioCloseOutSubmission = { +type FormioPRF2022Data = { [field: string]: unknown; - _id: string; // MongoDB ObjectId string - state: "submitted" | "draft"; - modified: string; // ISO 8601 date time string - metadata: { - [field: string]: unknown; - }; - data: { - [field: string]: unknown; - // fields injected upon new draft Payment Request submission creation: - bap_hidden_entity_combo_key: string; - hidden_prf_modified: string; // ISO 8601 date time string - hidden_current_user_email: string; - hidden_current_user_title: string; - hidden_current_user_name: string; - hidden_bap_rebate_id: string; - }; + // fields injected upon a new draft PRF submission creation: + bap_hidden_entity_combo_key: string; + hidden_application_form_modified: string; // ISO 8601 date time string + hidden_current_user_email: string; + hidden_current_user_title: string; + hidden_current_user_name: string; + hidden_bap_rebate_id: string; +}; + +type FormioCRF2022Data = { + [field: string]: unknown; + // fields injected upon a new draft CRF submission creation: + bap_hidden_entity_combo_key: string; + hidden_prf_modified: string; // ISO 8601 date time string + hidden_current_user_email: string; + hidden_current_user_title: string; + hidden_current_user_name: string; + hidden_bap_rebate_id: string; +}; + +type FormioFRF2023Data = { + [field: string]: unknown; + // fields injected upon a new draft FRF submission creation: + _user_email: string; + _user_title: string; + _user_name: string; + _bap_entity_combo_key: string; + _bap_applicant_email: string; + _bap_applicant_title: string; + _bap_applicant_name: string; + _bap_applicant_efti: string; + _bap_applicant_uei: string; + _bap_applicant_organization_name: string; + _bap_applicant_street_address_1: string; + _bap_applicant_street_address_2: string; + _bap_applicant_city: string; + _bap_applicant_state: string; + _bap_applicant_zip: string; + // fields set by form definition (among others): + appInfo_uei: string; + appInfo_efti: string; + appInfo_orgName: string; +}; + +export type FormioFRF2022Submission = FormioSubmission & { + data: FormioFRF2022Data; +}; + +export type FormioPRF2022Submission = FormioSubmission & { + data: FormioPRF2022Data; +}; + +export type FormioCRF2022Submission = FormioSubmission & { + data: FormioCRF2022Data; +}; + +export type FormioFRF2023Submission = FormioSubmission & { + data: FormioFRF2023Data; }; export type BapSubmission = { @@ -172,20 +199,37 @@ export type BapSubmission = { status: string | null; }; -export type Rebate = { - application: { - formio: FormioApplicationSubmission; - bap: BapSubmission | null; - }; - paymentRequest: { - formio: FormioPaymentRequestSubmission | null; - bap: BapSubmission | null; - }; - closeOut: { - formio: FormioCloseOutSubmission | null; - bap: BapSubmission | null; - }; -}; +export type Rebate = + | { + rebateYear: "2022"; + frf: { + formio: FormioFRF2022Submission; + bap: BapSubmission | null; + }; + prf: { + formio: FormioPRF2022Submission | null; + bap: BapSubmission | null; + }; + crf: { + formio: FormioCRF2022Submission | null; + bap: BapSubmission | null; + }; + } + | { + rebateYear: "2023"; + frf: { + formio: FormioFRF2023Submission; + bap: null; + }; + prf: { + formio: null; + bap: null; + }; + crf: { + formio: null; + bap: null; + }; + }; async function fetchData(url: string, options: RequestInit) { try { @@ -255,26 +299,26 @@ export function useUserData() { return queryClient.getQueryData(["user"]); } -/** Custom hook to fetch CSB data */ -export function useCsbQuery() { +/** Custom hook to fetch CSB config */ +export function useConfigQuery() { return useQuery({ - queryKey: ["csb-data"], - queryFn: () => getData(`${serverUrl}/api/csb-data`), + queryKey: ["config"], + queryFn: () => getData(`${serverUrl}/api/config`), refetchOnWindowFocus: false, }); } -/** Custom hook that returns cached fetched CSB data */ -export function useCsbData() { +/** Custom hook that returns cached fetched CSB config */ +export function useConfigData() { const queryClient = useQueryClient(); - return queryClient.getQueryData(["csb-data"]); + return queryClient.getQueryData(["config"]); } /** Custom hook to fetch BAP SAM.gov data */ export function useBapSamQuery() { return useQuery({ - queryKey: ["bap-sam-data"], - queryFn: () => getData(`${serverUrl}/api/bap-sam-data`), + queryKey: ["bap/sam"], + queryFn: () => getData(`${serverUrl}/api/bap/sam`), onSuccess: (res) => { if (!res.results) { window.location.href = `${serverUrlForHrefs}/logout?RelayState=/welcome?info=bap-sam-results`; @@ -290,121 +334,147 @@ export function useBapSamQuery() { /** Custom hook that returns cached fetched BAP SAM.gov data */ export function useBapSamData() { const queryClient = useQueryClient(); - return queryClient.getQueryData(["bap-sam-data"]); + return queryClient.getQueryData(["bap/sam"]); } /** Custom hook to fetch submissions from the BAP and Formio */ -export function useSubmissionsQueries() { - return useQueries({ - queries: [ - { - queryKey: ["bap-form-submissions"], - queryFn: () => { - const url = `${serverUrl}/api/bap-form-submissions`; - return getData(url).then((res) => { - const submissions = res.reduce( - (object, submission) => { - const formType = - submission.Record_Type_Name__c === "CSB Funding Request" - ? "applications" - : submission.Record_Type_Name__c === "CSB Payment Request" - ? "paymentRequests" - : submission.Record_Type_Name__c === "CSB Closeout Request" - ? "closeOuts" - : null; - - if (formType) object[formType].push(submission); - - return object; - }, - { - applications: [] as BapFormSubmission[], - paymentRequests: [] as BapFormSubmission[], - closeOuts: [] as BapFormSubmission[], - } - ); - - return Promise.resolve(submissions); - }); - }, - refetchOnWindowFocus: false, - }, - { - queryKey: ["formio-application-submissions"], - queryFn: () => { - const url = `${serverUrl}/api/formio-application-submissions`; - return getData(url); - }, - refetchOnWindowFocus: false, - }, - { - queryKey: ["formio-payment-request-submissions"], - queryFn: () => { - const url = `${serverUrl}/api/formio-payment-request-submissions`; - return getData(url); - }, - refetchOnWindowFocus: false, - }, - { - queryKey: ["formio-close-out-submissions"], - queryFn: () => { - const url = `${serverUrl}/api/formio-close-out-submissions`; - return getData(url); - }, - refetchOnWindowFocus: false, - }, - ], - }); -} +export function useSubmissionsQueries(rebateYear: RebateYear) { + const bapQuery = { + queryKey: ["bap/submissions"], + queryFn: () => { + const url = `${serverUrl}/api/bap/submissions`; + return getData(url).then((res) => { + const submissions = res.reduce( + (object, submission) => { + const formType = + submission.Record_Type_Name__c === "CSB Funding Request" + ? "frfs" + : submission.Record_Type_Name__c === "CSB Payment Request" + ? "prfs" + : submission.Record_Type_Name__c === "CSB Closeout Request" + ? "crfs" + : null; + + if (formType) object[formType].push(submission); + + return object; + }, + { + frfs: [] as BapFormSubmission[], + prfs: [] as BapFormSubmission[], + crfs: [] as BapFormSubmission[], + } + ); + + return Promise.resolve(submissions); + }); + }, + refetchOnWindowFocus: false, + }; -/** - * Custom hook to combine Application form submissions data, Payment Request - * form submissions data, and Close Out form submissions data from both the BAP - * and Formio into a single `submissions` object, with the BAP assigned - * `rebateId` as the keys. - **/ -function useCombinedRebates() { - const queryClient = useQueryClient(); + const formioFRF2022Query = { + queryKey: ["formio/2022/frf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2022/frf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; - const bapFormSubmissions = queryClient.getQueryData<{ - applications: BapFormSubmission[]; - paymentRequests: BapFormSubmission[]; - closeOuts: BapFormSubmission[]; - }>(["bap-form-submissions"]); + const formioPRF2022Query = { + queryKey: ["formio/2022/prf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2022/prf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + const formioCRF2022Query = { + queryKey: ["formio/2022/crf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2022/crf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + const formioFRF2023Query = { + queryKey: ["formio/2023/frf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2023/frf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + type Query = { + queryKey: string[]; + queryFn: () => + | Promise<{ + frfs: BapFormSubmission[]; + prfs: BapFormSubmission[]; + crfs: BapFormSubmission[]; + }> + | Promise + | Promise + | Promise + | Promise; + refetchOnWindowFocus: boolean; + }; - const formioApplicationSubmissions = queryClient.getQueryData< - FormioApplicationSubmission[] - >(["formio-application-submissions"]); + const queries: Query[] = + rebateYear === "2022" + ? [bapQuery, formioFRF2022Query, formioPRF2022Query, formioCRF2022Query] + : rebateYear === "2023" + ? [formioFRF2023Query] + : []; - const formioPaymentRequestSubmissions = queryClient.getQueryData< - FormioPaymentRequestSubmission[] - >(["formio-payment-request-submissions"]); + return useQueries({ queries }); +} - const formioCloseOutSubmissions = queryClient.getQueryData< - FormioCloseOutSubmission[] - >(["formio-close-out-submissions"]); +function combine2022Submissions(options: { + bapFormSubmissions: + | { + frfs: BapFormSubmission[]; + prfs: BapFormSubmission[]; + crfs: BapFormSubmission[]; + } + | undefined; + formioFRFSubmissions: FormioFRF2022Submission[] | undefined; + formioPRFSubmissions: FormioPRF2022Submission[] | undefined; + formioCRFSubmissions: FormioCRF2022Submission[] | undefined; +}) { + const { + bapFormSubmissions, + formioFRFSubmissions, + formioPRFSubmissions, + formioCRFSubmissions, + } = options; // ensure form submissions data has been fetched from both the BAP and Formio if ( !bapFormSubmissions || - !formioApplicationSubmissions || - !formioPaymentRequestSubmissions || - !formioCloseOutSubmissions + !formioFRFSubmissions || + !formioPRFSubmissions || + !formioCRFSubmissions ) { return {}; } - const rebates: { [rebateId: string]: Rebate } = {}; + const rebates: { + [rebateId: string]: Extract; + } = {}; /** - * Iterate over Formio Application form submissions, matching them with - * submissions returned from the BAP, so we can build up each rebate object - * with the Application form submission data and initialize Payment Request - * form and Close-out Form submission data structure (both to be updated). + * Iterate over Formio FRF submissions, matching them with submissions + * returned from the BAP, so we can build up each rebate object with the FRF + * submission data and initialize PRF and CRF submission data structure (both + * to be updated). */ - for (const formioSubmission of formioApplicationSubmissions) { - const bapMatch = bapFormSubmissions.applications.find((bapSub) => { - return bapSub.CSB_Form_ID__c === formioSubmission._id; + for (const formioFRFSubmission of formioFRFSubmissions) { + const bapMatch = bapFormSubmissions.frfs.find((bapFRFSubmission) => { + return bapFRFSubmission.CSB_Form_ID__c === formioFRFSubmission._id; }); const modified = bapMatch?.CSB_Modified_Full_String__c || null; @@ -415,43 +485,43 @@ function useCombinedRebates() { const status = bapMatch?.Parent_CSB_Rebate__r?.CSB_Funding_Request_Status__c || null; // prettier-ignore /** - * NOTE: If new Application form submissions have been reciently created in - * Formio and the BAP's ETL process has not yet run to pickup those new - * Formio submissions, all of the fields above will be null, so instead of + * NOTE: If new FRF submissions have been reciently created in Formio and + * the BAP's ETL process has not yet run to pickup those new Formio + * submissions, all of the fields above will be null, so instead of * assigning the submission's key as `rebateId` (which will be null), we'll * assign it to be an underscore concatenated with the Formio submission's * mongoDB ObjectID – just so each submission object still has a unique ID. */ - rebates[rebateId || `_${formioSubmission._id}`] = { - application: { - formio: { ...formioSubmission }, + rebates[rebateId || `_${formioFRFSubmission._id}`] = { + rebateYear: "2022", + frf: { + formio: { ...formioFRFSubmission }, bap: { modified, comboKey, mongoId, rebateId, reviewItemId, status }, }, - paymentRequest: { formio: null, bap: null }, - closeOut: { formio: null, bap: null }, + prf: { formio: null, bap: null }, + crf: { formio: null, bap: null }, }; } /** - * Iterate over Formio Payment Request form submissions, matching them with - * submissions returned from the BAP, so we can set the Payment Request form - * submission data. + * Iterate over Formio PRF submissions, matching them with submissions + * returned from the BAP, so we can set the PRF submission data. * - * NOTE: For there to be any Formio Payment Request form submissions at all, - * the BAP's ETL process must be running, as the `hidden_bap_rebate_id` field - * of a Payment Request form submission is injected in the creation of a brand - * new submission in the `/api/formio-payment-request-submission` POST request - * where he BAP Rebate ID (along with other fields) are fetched from the BAP - * and then posted to Formio in a new Payment Request form submission. + * NOTE: For there to be any Formio PRF submissions at all, the BAP's ETL + * process must be running, as the `hidden_bap_rebate_id` field of a PRF + * submission is injected in the creation of a brand new submission in the + * `/api/formio/2022/prf-submission` POST request where he BAP Rebate ID + * (along with other fields) are fetched from the BAP and then posted to + * Formio in a new PRF submission. * * That said, if the BAP ETL isn't returning data, we should make sure we * handle that situation gracefully (see NOTE below). */ - for (const formioSubmission of formioPaymentRequestSubmissions) { - const formioBapRebateId = formioSubmission.data.hidden_bap_rebate_id; + for (const formioPRFSubmission of formioPRFSubmissions) { + const formioBapRebateId = formioPRFSubmission.data.hidden_bap_rebate_id; - const bapMatch = bapFormSubmissions.paymentRequests.find((bapSub) => { - return bapSub.Parent_Rebate_ID__c === formioBapRebateId; + const bapMatch = bapFormSubmissions.prfs.find((bapPRFSubmission) => { + return bapPRFSubmission.Parent_Rebate_ID__c === formioBapRebateId; }); const modified = bapMatch?.CSB_Modified_Full_String__c || null; @@ -463,30 +533,28 @@ function useCombinedRebates() { /** * NOTE: If the BAP ETL is running, there should be a submission with a - * `formioBapRebateId` key for each Formio Payment Request form submission - * (as it would have been set in the `formioApplicationSubmissions` loop - * above). That said, we should first check that it exists before assigning - * the Payment Request data to it, so if the BAP ETL process isn't returning - * data, it won't break our app. + * `formioBapRebateId` key for each Formio PRF submission (as it would have + * been set in the `formioFRFSubmissions` loop above). That said, we should + * first check that it exists before assigning the PRF data to it, so if the + * BAP ETL process isn't returning data, it won't break our app. */ if (rebates[formioBapRebateId]) { - rebates[formioBapRebateId].paymentRequest = { - formio: { ...formioSubmission }, + rebates[formioBapRebateId].prf = { + formio: { ...formioPRFSubmission }, bap: { modified, comboKey, mongoId, rebateId, reviewItemId, status }, }; } } /** - * Iterate over Formio Close Out form submissions, matching them with - * submissions returned from the BAP, so we can set the Close Out form - * submission data. + * Iterate over Formio CRF submissions, matching them with submissions + * returned from the BAP, so we can set the CRF submission data. */ - for (const formioSubmission of formioCloseOutSubmissions) { - const formioBapRebateId = formioSubmission.data.hidden_bap_rebate_id; + for (const formioCRFSubmission of formioCRFSubmissions) { + const formioBapRebateId = formioCRFSubmission.data.hidden_bap_rebate_id; - const bapMatch = bapFormSubmissions.closeOuts.find((bapSub) => { - return bapSub.Parent_Rebate_ID__c === formioBapRebateId; + const bapMatch = bapFormSubmissions.crfs.find((bapCRFSubmission) => { + return bapCRFSubmission.Parent_Rebate_ID__c === formioBapRebateId; }); const modified = bapMatch?.CSB_Modified_Full_String__c || null; @@ -497,8 +565,8 @@ function useCombinedRebates() { const status = bapMatch?.Parent_CSB_Rebate__r?.CSB_Closeout_Request_Status__c || null; // prettier-ignore if (rebates[formioBapRebateId]) { - rebates[formioBapRebateId].closeOut = { - formio: { ...formioSubmission }, + rebates[formioBapRebateId].crf = { + formio: { ...formioCRFSubmission }, bap: { modified, comboKey, mongoId, rebateId, reviewItemId, status }, }; } @@ -507,32 +575,110 @@ function useCombinedRebates() { return rebates; } +function combine2023Submissions(options: { + formioFRFSubmissions: FormioFRF2023Submission[] | undefined; +}) { + const { formioFRFSubmissions } = options; + + // ensure form submissions data has been fetched from Formio + if (!formioFRFSubmissions) { + return {}; + } + + const rebates: { + [rebateId: string]: Extract; + } = {}; + + /** + * Iterate over Formio FRF submissions so we can build up each rebate object + * with the FRF submission data and initialize PRF and CRF submission data + * structure (both to be updated). + */ + for (const formioFRFSubmission of formioFRFSubmissions) { + rebates[`_${formioFRFSubmission._id}`] = { + rebateYear: "2023", + frf: { + formio: { ...formioFRFSubmission }, + bap: null, + }, + prf: { formio: null, bap: null }, + crf: { formio: null, bap: null }, + }; + } + + return rebates; +} + +/** + * Custom hook to combine FRF submissions, PRF submissions, and CRF submissions + * from both the BAP and Formio into a single object, with the BAP assigned + * rebateId as the object's keys. + **/ +function useCombinedSubmissions(rebateYear: RebateYear) { + const queryClient = useQueryClient(); + + const bapFormSubmissions = queryClient.getQueryData<{ + frfs: BapFormSubmission[]; + prfs: BapFormSubmission[]; + crfs: BapFormSubmission[]; + }>(["bap/submissions"]); + + const formioFRF2022Submissions = queryClient.getQueryData< + FormioFRF2022Submission[] + >(["formio/2022/frf-submissions"]); + + const formioPRF2022Submissions = queryClient.getQueryData< + FormioPRF2022Submission[] + >(["formio/2022/prf-submissions"]); + + const formioCRF2022Submissions = queryClient.getQueryData< + FormioCRF2022Submission[] + >(["formio/2022/crf-submissions"]); + + const formioFRF2023Submissions = queryClient.getQueryData< + FormioFRF2023Submission[] + >(["formio/2023/frf-submissions"]); + + const submissions = + rebateYear === "2022" + ? combine2022Submissions({ + bapFormSubmissions, + formioFRFSubmissions: formioFRF2022Submissions, + formioPRFSubmissions: formioPRF2022Submissions, + formioCRFSubmissions: formioCRF2022Submissions, + }) + : rebateYear === "2023" + ? combine2023Submissions({ + formioFRFSubmissions: formioFRF2023Submissions, + }) + : {}; + + return submissions; +} + /** - * Custom hook that sorts rebates by: - * - Most recient Formio modified date, regardless of form type - * (Application, Payment Request, or Close Out) + * Custom hook that sorts submissions by: + * - Most recient Formio modified date, regardless of form type (FRF, PRF, CRF) * - Submissions needing edits, regardless of form type - * - Selected Application form submissions without a corresponding Payment - * Request form submission - * - Funding Approved Payment Request form submissions without a corresponding - * Close Out form submission + * - Selected FRF submissions without a corresponding PRF submission + * - Funding Approved PRF submissions without a corresponding CRF submission **/ -function useSortedRebates(rebates: { [rebateId: string]: Rebate }) { +function useSortedSubmissions(rebates: { [rebateId: string]: Rebate }) { return Object.entries(rebates) .map(([rebateId, rebate]) => ({ rebateId, ...rebate })) .sort((r1, r2) => { const mostRecientR1Modified = [ - Date.parse(r1.application.formio.modified), - Date.parse(r1.paymentRequest.formio?.modified || ""), - Date.parse(r1.closeOut.formio?.modified || ""), + Date.parse(r1.frf.formio.modified), + Date.parse(r1.prf.formio?.modified || ""), + Date.parse(r1.crf.formio?.modified || ""), ].reduce((previous, current) => { return current > previous ? current : previous; }); const mostRecientR2Modified = [ - Date.parse(r2.application.formio.modified), - Date.parse(r2.paymentRequest.formio?.modified || ""), - Date.parse(r2.closeOut.formio?.modified || ""), + Date.parse(r2.frf.formio.modified), + Date.parse(r2.prf.formio?.modified || ""), + Date.parse(r2.crf.formio?.modified || ""), ].reduce((previous, current) => { return current > previous ? current : previous; }); @@ -540,60 +686,58 @@ function useSortedRebates(rebates: { [rebateId: string]: Rebate }) { return mostRecientR2Modified - mostRecientR1Modified; }) .sort((r1, _r2) => { - const r1ApplicationNeedsEdits = submissionNeedsEdits({ - formio: r1.application.formio, - bap: r1.application.bap, + const r1FRFNeedsEdits = submissionNeedsEdits({ + formio: r1.frf.formio, + bap: r1.frf.bap, }); - const r1PaymentRequestNeedsEdits = submissionNeedsEdits({ - formio: r1.paymentRequest.formio, - bap: r1.paymentRequest.bap, + const r1PRFNeedsEdits = submissionNeedsEdits({ + formio: r1.prf.formio, + bap: r1.prf.bap, }); - const r1CloseOutNeedsEdits = submissionNeedsEdits({ - formio: r1.closeOut.formio, - bap: r1.closeOut.bap, + const r1CRFNeedsEdits = submissionNeedsEdits({ + formio: r1.crf.formio, + bap: r1.crf.bap, }); - const r1ApplicationSelected = r1.application.bap?.status === "Accepted"; + const r1FRFSelected = r1.frf.bap?.status === "Accepted"; - const r1ApplicationSelectedButNoPaymentRequest = - r1ApplicationSelected && !Boolean(r1.paymentRequest.formio); + const r1FRFSelectedButNoPRF = r1FRFSelected && !Boolean(r1.prf.formio); - const r1PaymentRequestFundingApproved = - r1.paymentRequest.bap?.status === "Accepted"; + const r1PRFFundingApproved = r1.prf.bap?.status === "Accepted"; - const r1PaymentRequestFundingApprovedButNoCloseOut = - r1PaymentRequestFundingApproved && !Boolean(r1.closeOut.formio); + const r1PRFFundingApprovedButNoCRF = + r1PRFFundingApproved && !Boolean(r1.crf.formio); - return r1ApplicationNeedsEdits || - r1PaymentRequestNeedsEdits || - r1CloseOutNeedsEdits || - r1ApplicationSelectedButNoPaymentRequest || - r1PaymentRequestFundingApprovedButNoCloseOut + return r1FRFNeedsEdits || + r1PRFNeedsEdits || + r1CRFNeedsEdits || + r1FRFSelectedButNoPRF || + r1PRFFundingApprovedButNoCRF ? -1 : 0; }); } /** - * Custom hook that returns sorted rebates, and logs them if 'debug' search + * Custom hook that returns sorted submissions, and logs them if 'debug' search * parameter exists. */ -export function useRebates() { +export function useSubmissions(rebateYear: RebateYear) { const [searchParams] = useSearchParams(); - const combinedRebates = useCombinedRebates(); - const sortedRebates = useSortedRebates(combinedRebates); + const combinedSubmissions = useCombinedSubmissions(rebateYear); + const sortedSubmissions = useSortedSubmissions(combinedSubmissions); - // log combined 'sortedRebates' array if 'debug' search parameter exists + // log combined 'sortedSubmissions' array if 'debug' search parameter exists useEffect(() => { - if (searchParams.has("debug") && sortedRebates.length > 0) { - console.log(sortedRebates); + if (searchParams.has("debug") && sortedSubmissions.length > 0) { + console.log(sortedSubmissions); } - }, [searchParams, sortedRebates]); + }, [searchParams, sortedSubmissions]); - return sortedRebates; + return sortedSubmissions; } /** @@ -605,9 +749,10 @@ export function useRebates() { */ export function submissionNeedsEdits(options: { formio: - | FormioApplicationSubmission - | FormioPaymentRequestSubmission - | FormioCloseOutSubmission + | FormioFRF2022Submission + | FormioPRF2022Submission + | FormioCRF2022Submission + | FormioFRF2023Submission | null; bap: BapSubmission | null; }) { diff --git a/app/client/tailwind.config.js b/app/client/tailwind.config.js index 85954397..bc04a801 100644 --- a/app/client/tailwind.config.js +++ b/app/client/tailwind.config.js @@ -4,7 +4,7 @@ module.exports = { theme: { extend: {}, }, - plugins: [], + plugins: [require("@tailwindcss/forms")], prefix: "tw-", corePlugins: { preflight: false, diff --git a/app/server/.env.example b/app/server/.env.example index 7c47ade4..f6eeeb2c 100644 --- a/app/server/.env.example +++ b/app/server/.env.example @@ -10,14 +10,20 @@ SAML_IDP_CERT= SAML_PUBLIC_KEY=publickey JWT_PRIVATE_KEY=secret JWT_PUBLIC_KEY=secret -CSB_APPLICATION_FORM_OPEN=true -CSB_PAYMENT_REQUEST_FORM_OPEN=true -CSB_CLOSE_OUT_FORM_OPEN=true +CSB_2022_FRF_OPEN=true +CSB_2022_PRF_OPEN=true +CSB_2022_CRF_OPEN=true +CSB_2023_FRF_OPEN=true +CSB_2023_PRF_OPEN=true +CSB_2023_CRF_OPEN=true +FORMIO_2022_FRF_PATH= +FORMIO_2022_PRF_PATH= +FORMIO_2022_CRF_PATH= +FORMIO_2023_FRF_PATH= +FORMIO_2023_PRF_PATH= +FORMIO_2023_CRF_PATH= FORMIO_BASE_URL= FORMIO_PROJECT_NAME= -FORMIO_APPLICATION_FORM_PATH= -FORMIO_PAYMENT_REQUEST_FORM_PATH= -FORMIO_CLOSE_OUT_FORM_PATH= FORMIO_API_KEY= BAP_CLIENT_ID= BAP_CLIENT_SECRET= diff --git a/app/server/app/config/formio.js b/app/server/app/config/formio.js index 7bf1a588..68f90b47 100644 --- a/app/server/app/config/formio.js +++ b/app/server/app/config/formio.js @@ -6,18 +6,56 @@ const log = require("../utilities/logger"); const { CLOUD_SPACE, SERVER_URL, + CSB_2022_FRF_OPEN, + CSB_2022_PRF_OPEN, + CSB_2022_CRF_OPEN, + CSB_2023_FRF_OPEN, + CSB_2023_PRF_OPEN, + CSB_2023_CRF_OPEN, FORMIO_BASE_URL, FORMIO_PROJECT_NAME, - FORMIO_APPLICATION_FORM_PATH, - FORMIO_PAYMENT_REQUEST_FORM_PATH, - FORMIO_CLOSE_OUT_FORM_PATH, FORMIO_API_KEY, + FORMIO_2022_FRF_PATH, + FORMIO_2022_PRF_PATH, + FORMIO_2022_CRF_PATH, + FORMIO_2023_FRF_PATH, + FORMIO_2023_PRF_PATH, + FORMIO_2023_CRF_PATH, } = process.env; const formioProjectUrl = `${FORMIO_BASE_URL}/${FORMIO_PROJECT_NAME}`; -const formioApplicationFormUrl = `${formioProjectUrl}/${FORMIO_APPLICATION_FORM_PATH}`; -const formioPaymentRequestFormUrl = `${formioProjectUrl}/${FORMIO_PAYMENT_REQUEST_FORM_PATH}`; -const formioCloseOutFormUrl = `${formioProjectUrl}/${FORMIO_CLOSE_OUT_FORM_PATH}`; + +/** + * Stores form url for each form by rebate year. + */ +const formUrl = { + 2022: { + frf: `${formioProjectUrl}/${FORMIO_2022_FRF_PATH}`, + prf: `${formioProjectUrl}/${FORMIO_2022_PRF_PATH}`, + crf: `${formioProjectUrl}/${FORMIO_2022_CRF_PATH}`, + }, + 2023: { + frf: `${formioProjectUrl}/${FORMIO_2023_FRF_PATH}`, + prf: `${formioProjectUrl}/${FORMIO_2023_PRF_PATH}`, + crf: `${formioProjectUrl}/${FORMIO_2023_CRF_PATH}`, + }, +}; + +/** + * Stores whether the submission period is open for each form by rebate year. + */ +const submissionPeriodOpen = { + 2022: { + frf: CSB_2022_FRF_OPEN === "true", + prf: CSB_2022_PRF_OPEN === "true", + crf: CSB_2022_CRF_OPEN === "true", + }, + 2023: { + frf: CSB_2023_FRF_OPEN === "true", + prf: CSB_2023_PRF_OPEN === "true", + crf: CSB_2023_CRF_OPEN === "true", + }, +}; /** @param {express.Request} req */ function axiosFormio(req) { @@ -75,15 +113,14 @@ function axiosFormio(req) { return instance; } -const formioCsbMetadata = { +const formioCSBMetadata = { "csb-app-cloud-space": `env-${CLOUD_SPACE || "local"}`, "csb-app-cloud-origin": SERVER_URL || "localhost", }; module.exports = { axiosFormio, - formioApplicationFormUrl, - formioPaymentRequestFormUrl, - formioCloseOutFormUrl, - formioCsbMetadata, + formUrl, + submissionPeriodOpen, + formioCSBMetadata, }; diff --git a/app/server/app/content/draft-close-out-intro.md b/app/server/app/content/draft-crf-intro.md similarity index 100% rename from app/server/app/content/draft-close-out-intro.md rename to app/server/app/content/draft-crf-intro.md diff --git a/app/server/app/content/draft-application-intro.md b/app/server/app/content/draft-frf-intro.md similarity index 100% rename from app/server/app/content/draft-application-intro.md rename to app/server/app/content/draft-frf-intro.md diff --git a/app/server/app/content/draft-payment-request-intro.md b/app/server/app/content/draft-prf-intro.md similarity index 100% rename from app/server/app/content/draft-payment-request-intro.md rename to app/server/app/content/draft-prf-intro.md diff --git a/app/server/app/content/new-application-dialog.md b/app/server/app/content/new-frf-dialog.md similarity index 100% rename from app/server/app/content/new-application-dialog.md rename to app/server/app/content/new-frf-dialog.md diff --git a/app/server/app/content/submitted-close-out-intro.md b/app/server/app/content/submitted-crf-intro.md similarity index 100% rename from app/server/app/content/submitted-close-out-intro.md rename to app/server/app/content/submitted-crf-intro.md diff --git a/app/server/app/content/submitted-application-intro.md b/app/server/app/content/submitted-frf-intro.md similarity index 100% rename from app/server/app/content/submitted-application-intro.md rename to app/server/app/content/submitted-frf-intro.md diff --git a/app/server/app/content/submitted-payment-request-intro.md b/app/server/app/content/submitted-prf-intro.md similarity index 100% rename from app/server/app/content/submitted-payment-request-intro.md rename to app/server/app/content/submitted-prf-intro.md diff --git a/app/server/app/index.js b/app/server/app/index.js index cee70d6f..f42418bb 100644 --- a/app/server/app/index.js +++ b/app/server/app/index.js @@ -36,14 +36,20 @@ const requiredEnvironmentVariables = [ "SAML_PUBLIC_KEY", "JWT_PRIVATE_KEY", "JWT_PUBLIC_KEY", - "CSB_APPLICATION_FORM_OPEN", - "CSB_PAYMENT_REQUEST_FORM_OPEN", - "CSB_CLOSE_OUT_FORM_OPEN", + "CSB_2022_FRF_OPEN", + "CSB_2022_PRF_OPEN", + "CSB_2022_CRF_OPEN", + "CSB_2023_FRF_OPEN", + "CSB_2023_PRF_OPEN", + "CSB_2023_CRF_OPEN", + "FORMIO_2022_FRF_PATH", + "FORMIO_2022_PRF_PATH", + "FORMIO_2022_CRF_PATH", + "FORMIO_2023_FRF_PATH", + "FORMIO_2023_PRF_PATH", + "FORMIO_2023_CRF_PATH", "FORMIO_BASE_URL", "FORMIO_PROJECT_NAME", - "FORMIO_APPLICATION_FORM_PATH", - "FORMIO_PAYMENT_REQUEST_FORM_PATH", - "FORMIO_CLOSE_OUT_FORM_PATH", "FORMIO_API_KEY", "S3_PUBLIC_BUCKET", "S3_PUBLIC_REGION", diff --git a/app/server/app/middleware.js b/app/server/app/middleware.js index 5e8b2233..d8e3c16b 100644 --- a/app/server/app/middleware.js +++ b/app/server/app/middleware.js @@ -164,15 +164,16 @@ function protectClientRoutes(req, res, next) { */ function checkClientRouteExists(req, res, next) { const subPath = SERVER_BASE_PATH || ""; - const clientRoutes = ["/", "/welcome", "/helpdesk", "/rebate/new"].map( + + const clientRoutes = ["/", "/welcome", "/helpdesk", "/frf/new"].map( (route) => `${subPath}${route}` ); if ( !clientRoutes.includes(req.path) && - !req.path.includes("/rebate/") && - !req.path.includes("/payment-request/") && - !req.path.includes("/close-out/") + !req.path.includes("/frf/") && + !req.path.includes("/prf/") && + !req.path.includes("/crf/") ) { const errorStatus = 404; return res diff --git a/app/server/app/routes/api.js b/app/server/app/routes/api.js deleted file mode 100644 index ef976530..00000000 --- a/app/server/app/routes/api.js +++ /dev/null @@ -1,1175 +0,0 @@ -const { resolve } = require("node:path"); -const { readFile } = require("node:fs/promises"); -const express = require("express"); -const axios = require("axios").default || require("axios"); // TODO: https://github.com/axios/axios/issues/5011 -const ObjectId = require("mongodb").ObjectId; -// --- -const { - axiosFormio, - formioApplicationFormUrl, - formioPaymentRequestFormUrl, - formioCloseOutFormUrl, - formioCsbMetadata, -} = require("../config/formio"); -const { - ensureAuthenticated, - storeBapComboKeys, - verifyMongoObjectId, -} = require("../middleware"); -const { - getSamEntities, - getBapFormSubmissionsStatuses, - getBapDataForPaymentRequest, - getBapDataForCloseOut, -} = require("../utilities/bap"); -const log = require("../utilities/logger"); - -const { - NODE_ENV, - CSB_APPLICATION_FORM_OPEN, - CSB_PAYMENT_REQUEST_FORM_OPEN, - CSB_CLOSE_OUT_FORM_OPEN, - S3_PUBLIC_BUCKET, - S3_PUBLIC_REGION, -} = process.env; - -const applicationFormOpen = CSB_APPLICATION_FORM_OPEN === "true"; -const paymentRequestFormOpen = CSB_PAYMENT_REQUEST_FORM_OPEN === "true"; -const closeOutFormOpen = CSB_CLOSE_OUT_FORM_OPEN === "true"; - -/** - * Returns a resolved or rejected promise, depending on if the given form's - * submission period is open (as set via environment variables), and if the form - * submission has the status of "Edits Requested" or not (as stored in and - * returned from the BAP). - * - * @param {Object} param - * @param {'application' | 'payment-request' | 'close-out'} param.formType - * @param {string} param.mongoId - * @param {string} param.comboKey - * @param {express.Request} param.req - */ -function checkFormSubmissionPeriodAndBapStatus({ - formType, - mongoId, - comboKey, - req, -}) { - /** Form submission period is open, so continue. */ - if ( - (formType === "application" && applicationFormOpen) || - (formType === "payment-request" && paymentRequestFormOpen) || - (formType === "close-out" && closeOutFormOpen) - ) { - return Promise.resolve(); - } - - /** Form submission period is closed, so only continue if edits are requested. */ - return getBapFormSubmissionsStatuses(req, [comboKey]).then((submissions) => { - const submission = submissions.find((s) => s.CSB_Form_ID__c === mongoId); - - const statusField = - formType === "application" - ? "CSB_Funding_Request_Status__c" - : formType === "payment-request" - ? "CSB_Payment_Request_Status__c" - : formType === "close-out" - ? "CSB_Closeout_Request_Status__c" - : null; - - return submission?.Parent_CSB_Rebate__r?.[statusField] === "Edits Requested" - ? Promise.resolve() - : Promise.reject(); - }); -} - -const router = express.Router(); - -// --- get static content from S3 -router.get("/content", (req, res) => { - /** NOTE: static content files found in `app/server/app/content/` directory. */ - const filenames = [ - "site-alert.md", - "helpdesk-intro.md", - "all-rebates-intro.md", - "all-rebates-outro.md", - "new-application-dialog.md", - "draft-application-intro.md", - "submitted-application-intro.md", - "draft-payment-request-intro.md", - "submitted-payment-request-intro.md", - "draft-close-out-intro.md", - "submitted-close-out-intro.md", - ]; - - const s3BucketUrl = `https://${S3_PUBLIC_BUCKET}.s3-${S3_PUBLIC_REGION}.amazonaws.com`; - - Promise.all( - filenames.map((filename) => { - /** - * local development: read files directly from disk - * Cloud.gov: fetch files from the public s3 bucket - */ - return NODE_ENV === "development" - ? readFile(resolve(__dirname, "../content", filename), "utf8") - : axios.get(`${s3BucketUrl}/content/${filename}`); - }) - ) - .then((stringsOrResponses) => { - /** - * local development: no further processing of strings needed - * Cloud.gov: get data from responses - */ - return NODE_ENV === "development" - ? stringsOrResponses - : stringsOrResponses.map((axiosRes) => axiosRes.data); - }) - .then((data) => { - return res.json({ - siteAlert: data[0], - helpdeskIntro: data[1], - allRebatesIntro: data[2], - allRebatesOutro: data[3], - newApplicationDialog: data[4], - draftApplicationIntro: data[5], - submittedApplicationIntro: data[6], - draftPaymentRequestIntro: data[7], - submittedPaymentRequestIntro: data[8], - draftCloseOutIntro: data[9], - submittedCloseOutIntro: data[10], - }); - }) - .catch((error) => { - if (typeof error.toJSON === "function") { - const logMessage = error.toJSON(); - log({ level: "debug", message: logMessage, req }); - } - - const errorStatus = error.response?.status || 500; - const errorMethod = error.response?.config?.method?.toUpperCase(); - const errorUrl = error.response?.config?.url; - - const logMessage = `S3 Error: ${errorStatus} ${errorMethod} ${errorUrl}`; - log({ level: "error", message: logMessage, req }); - - const errorMessage = `Error getting static content from S3 bucket.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -router.use(ensureAuthenticated); - -// --- get user data from EPA Gateway/Login.gov -router.get("/user", (req, res) => { - const { mail, memberof, exp } = req.user; - return res.json({ mail, memberof, exp }); -}); - -// --- get CSB app specific data (open enrollment status, etc.) -router.get("/csb-data", (req, res) => { - return res.json({ - submissionPeriodOpen: { - application: applicationFormOpen, - paymentRequest: paymentRequestFormOpen, - closeOut: closeOutFormOpen, - }, - }); -}); - -// --- get user's SAM.gov data from EPA's Business Automation Platform (BAP) -router.get("/bap-sam-data", (req, res) => { - const { mail, memberof } = req.user; - const userRoles = memberof.split(","); - const adminOrHelpdeskUser = - userRoles.includes("csb_admin") || userRoles.includes("csb_helpdesk"); - - getSamEntities(req, mail) - .then((entities) => { - /** - * NOTE: allow admin or helpdesk users access to the app, even without - * SAM.gov data. - */ - if (!adminOrHelpdeskUser && entities?.length === 0) { - const logMessage = `User with email '${mail}' tried to use app without any associated SAM records.`; - log({ level: "error", message: logMessage, req }); - - return res.json({ - results: false, - entities: [], - }); - } - - return res.json({ - results: true, - entities, - }); - }) - .catch((error) => { - // NOTE: logged in bap verifyBapConnection - const errorStatus = 500; - const errorMessage = `Error getting SAM.gov data from the BAP.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- get user's form submissions statuses from EPA's BAP -router.get("/bap-form-submissions", storeBapComboKeys, (req, res) => { - const { bapComboKeys } = req; - - return getBapFormSubmissionsStatuses(req, bapComboKeys) - .then((submissions) => res.json(submissions)) - .catch((error) => { - // NOTE: logged in bap verifyBapConnection - const errorStatus = 500; - const errorMessage = `Error getting form submissions statuses from the BAP.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- download Formio S3 file metadata -router.get( - "/s3/:formType/:mongoId/:comboKey/storage/s3", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, query } = req; - const { mail } = req.user; - const { comboKey } = req.params; - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to download a file without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - axiosFormio(req) - .get(`${formioApplicationFormUrl}/storage/s3`, { params: query }) - .then((axiosRes) => axiosRes.data) - .then((fileMetadata) => res.json(fileMetadata)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error downloading file from S3.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- upload Formio S3 file metadata -router.post( - "/s3/:formType/:mongoId/:comboKey/storage/s3", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { formType, mongoId, comboKey } = req.params; - - checkFormSubmissionPeriodAndBapStatus({ formType, mongoId, comboKey, req }) - .then(() => { - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to upload a file without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - axiosFormio(req) - .post(`${formioApplicationFormUrl}/storage/s3`, body) - .then((axiosRes) => axiosRes.data) - .then((fileMetadata) => res.json(fileMetadata)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error uploading file to S3.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - const formName = - formType === "application" - ? "CSB Application" - : formType === "payment-request" - ? "CSB Payment Request" - : formType === "close-out" - ? "CSB Close Out" - : "CSB"; - - const logMessage = `User with email '${mail}' attempted to upload a file when the ${formName} form enrollment period was closed.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 400; - const errorMessage = `${formName} form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- get user's Application form submissions from Formio -router.get("/formio-application-submissions", storeBapComboKeys, (req, res) => { - const { bapComboKeys } = req; - - /** - * NOTE: Helpdesk users might not have any SAM.gov records associated with - * their email address so we should not return any submissions to those users. - * The only reason we explicitly need to do this is because there could be - * some submissions without `bap_hidden_entity_combo_key` field values in the - * Formio database – that will never be the case for submissions created from - * this app, but there could be submissions created externally if someone is - * testing posting data (e.g. from a REST client, or the Formio Viewer). - */ - if (bapComboKeys.length === 0) return res.json([]); - - const userSubmissionsUrl = - `${formioApplicationFormUrl}/submission` + - `?sort=-modified` + - `&limit=1000000` + - `&data.bap_hidden_entity_combo_key=` + - `${bapComboKeys.join("&data.bap_hidden_entity_combo_key=")}`; - - axiosFormio(req) - .get(userSubmissionsUrl) - .then((axiosRes) => axiosRes.data) - .then((submissions) => res.json(submissions)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Application form submissions.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- post a new Application form submission to Formio -router.post("/formio-application-submission", storeBapComboKeys, (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const comboKey = body.data?.bap_hidden_entity_combo_key; - - if (!applicationFormOpen) { - const errorStatus = 400; - const errorMessage = `CSB Application form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to post a new Application form submission without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** Add custom metadata to track formio submissions from wrapper. */ - body.metadata = { ...formioCsbMetadata }; - - axiosFormio(req) - .post(`${formioApplicationFormUrl}/submission`, body) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error posting Formio Application form submission.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- get an existing Application form's schema and submission data from Formio -router.get( - "/formio-application-submission/:mongoId", - verifyMongoObjectId, - storeBapComboKeys, - (req, res) => { - const { bapComboKeys } = req; - const { mail } = req.user; - const { mongoId } = req.params; - - Promise.all([ - axiosFormio(req).get(`${formioApplicationFormUrl}/submission/${mongoId}`), - axiosFormio(req).get(formioApplicationFormUrl), - ]) - .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) - .then(([submission, schema]) => { - const comboKey = submission.data.bap_hidden_entity_combo_key; - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to access Application form submission '${mongoId}' that they do not have access to.`; - log({ level: "warn", message: logMessage, req }); - - return res.json({ - userAccess: false, - formSchema: null, - submission: null, - }); - } - - return res.json({ - userAccess: true, - formSchema: { url: formioApplicationFormUrl, json: schema }, - submission, - }); - }) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Application form submission '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- post an update to an existing draft Application form submission to Formio -router.post( - "/formio-application-submission/:mongoId", - verifyMongoObjectId, - storeBapComboKeys, - (req, res) => { - const { bapComboKeys } = req; - const { mail } = req.user; - const { mongoId } = req.params; - const submission = req.body; - const comboKey = submission.data?.bap_hidden_entity_combo_key; - const formType = "application"; - - checkFormSubmissionPeriodAndBapStatus({ formType, mongoId, comboKey, req }) - .then(() => { - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to update Application form submission '${mongoId}' without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** Add custom metadata to track formio submissions from wrapper. */ - submission.metadata = { - ...submission.metadata, - ...formioCsbMetadata, - }; - - axiosFormio(req) - .put(`${formioApplicationFormUrl}/submission/${mongoId}`, submission) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error updating Formio Application form submission '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - const logMessage = `User with email '${mail}' attempted to update Application form submission '${mongoId}' when the CSB Application form enrollment period was closed.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 400; - const errorMessage = `CSB Application form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- get user's Payment Request form submissions from Formio -router.get( - "/formio-payment-request-submissions", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys } = req; - - const userSubmissionsUrl = - `${formioPaymentRequestFormUrl}/submission` + - `?sort=-modified` + - `&limit=1000000` + - `&data.bap_hidden_entity_combo_key=${bapComboKeys.join( - "&data.bap_hidden_entity_combo_key=" - )}`; - - axiosFormio(req) - .get(userSubmissionsUrl) - .then((axiosRes) => axiosRes.data) - .then((submissions) => res.json(submissions)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Payment Request form submissions.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- post a new Payment Request form submission to Formio -router.post( - "/formio-payment-request-submission", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { - email, - title, - name, - entity, - comboKey, - rebateId, - applicationReviewItemId, - applicationFormModified, - } = body; - - if (!paymentRequestFormOpen) { - const errorStatus = 400; - const errorMessage = `CSB Payment Request form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to post a new Payment Request form submission without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - const { - UNIQUE_ENTITY_ID__c, - ENTITY_EFT_INDICATOR__c, - ELEC_BUS_POC_EMAIL__c, - ALT_ELEC_BUS_POC_EMAIL__c, - GOVT_BUS_POC_EMAIL__c, - ALT_GOVT_BUS_POC_EMAIL__c, - } = entity; - - return getBapDataForPaymentRequest(req, applicationReviewItemId) - .then(({ applicationRecordQuery, busRecordsQuery }) => { - const { - CSB_NCES_ID__c, - Primary_Applicant__r, - Alternate_Applicant__r, - Applicant_Organization__r, - CSB_School_District__r, - Fleet_Name__c, - School_District_Prioritized__c, - Total_Rebate_Funds_Requested__c, - Total_Infrastructure_Funds__c, - } = applicationRecordQuery[0]; - - const busInfo = busRecordsQuery.map((record) => ({ - busNum: record.Rebate_Item_num__c, - oldBusNcesDistrictId: CSB_NCES_ID__c, - oldBusVin: record.CSB_VIN__c, - oldBusModelYear: record.CSB_Model_Year__c, - oldBusFuelType: record.CSB_Fuel_Type__c, - newBusFuelType: record.CSB_Replacement_Fuel_Type__c, - hidden_bap_max_rebate: record.CSB_Funds_Requested__c, - })); - - /** - * NOTE: `purchaseOrders` is initialized as an empty array to fix some - * issue with the field being changed to an object when the form loads - */ - const submission = { - data: { - bap_hidden_entity_combo_key: comboKey, - hidden_application_form_modified: applicationFormModified, - hidden_current_user_email: email, - hidden_current_user_title: title, - hidden_current_user_name: name, - hidden_sam_uei: UNIQUE_ENTITY_ID__c, - hidden_sam_efti: ENTITY_EFT_INDICATOR__c || "0000", - hidden_sam_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, - hidden_sam_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, - hidden_sam_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, - hidden_sam_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, - hidden_bap_rebate_id: rebateId, - hidden_bap_district_id: CSB_NCES_ID__c, - hidden_bap_primary_name: Primary_Applicant__r?.Name, - hidden_bap_primary_title: Primary_Applicant__r?.Title, - hidden_bap_primary_phone_number: Primary_Applicant__r?.Phone, - hidden_bap_primary_email: Primary_Applicant__r?.Email, - hidden_bap_alternate_name: Alternate_Applicant__r?.Name || "", - hidden_bap_alternate_title: Alternate_Applicant__r?.Title || "", - hidden_bap_alternate_phone_number: Alternate_Applicant__r?.Phone || "", // prettier-ignore - hidden_bap_alternate_email: Alternate_Applicant__r?.Email || "", - hidden_bap_org_name: Applicant_Organization__r?.Name, - hidden_bap_district_name: CSB_School_District__r?.Name, - hidden_bap_fleet_name: Fleet_Name__c, - hidden_bap_prioritized: School_District_Prioritized__c, - hidden_bap_requested_funds: Total_Rebate_Funds_Requested__c, - hidden_bap_infra_max_rebate: Total_Infrastructure_Funds__c, - busInfo, - purchaseOrders: [], - }, - /** Add custom metadata to track formio submissions from wrapper. */ - metadata: { - ...formioCsbMetadata, - }, - state: "draft", - }; - - axiosFormio(req) - .post(`${formioPaymentRequestFormUrl}/submission`, submission) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error posting Formio Payment Request form submission.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - // NOTE: logged in bap verifyBapConnection - const errorStatus = 500; - const errorMessage = `Error getting data for a new Payment Request form submission from the BAP.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- get an existing Payment Request form's schema and submission data from Formio -router.get( - "/formio-payment-request-submission/:rebateId", - storeBapComboKeys, - async (req, res) => { - const { bapComboKeys } = req; - const { mail } = req.user; - const { rebateId } = req.params; // CSB Rebate ID (6 digits) - - const matchedPaymentRequestFormSubmissions = - `${formioPaymentRequestFormUrl}/submission` + - `?data.hidden_bap_rebate_id=${rebateId}` + - `&select=_id,data.bap_hidden_entity_combo_key`; - - Promise.all([ - axiosFormio(req).get(matchedPaymentRequestFormSubmissions), - axiosFormio(req).get(formioPaymentRequestFormUrl), - ]) - .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) - .then(([submissions, schema]) => { - const submission = submissions[0]; - const mongoId = submission._id; - const comboKey = submission.data.bap_hidden_entity_combo_key; - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to access Payment Request form submission '${rebateId}' that they do not have access to.`; - log({ level: "warn", message: logMessage, req }); - - return res.json({ - userAccess: false, - formSchema: null, - submission: null, - }); - } - - /** NOTE: verifyMongoObjectId */ - if (mongoId && !ObjectId.isValid(mongoId)) { - const errorStatus = 400; - const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** - * NOTE: We can't just use the returned submission data here because - * Formio returns the string literal 'YES' instead of a base64 encoded - * image string for signature fields when you query for all submissions - * matching on a field's value (`/submission?data.hidden_bap_rebate_id=${rebateId}`). - * We need to query for a specific submission (e.g. `/submission/${mongoId}`), - * to have Formio return the correct signature field data. - */ - axiosFormio(req) - .get(`${formioPaymentRequestFormUrl}/submission/${mongoId}`) - .then((axiosRes) => axiosRes.data) - .then((submission) => { - return res.json({ - userAccess: true, - formSchema: { url: formioPaymentRequestFormUrl, json: schema }, - submission, - }); - }); - }) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Payment Request form submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- post an update to an existing draft Payment Request form submission to Formio -router.post( - "/formio-payment-request-submission/:rebateId", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { rebateId } = req.params; // CSB Rebate ID (6 digits) - const { mongoId, submission } = body; - const comboKey = submission.data?.bap_hidden_entity_combo_key; - const formType = "payment-request"; - - checkFormSubmissionPeriodAndBapStatus({ formType, mongoId, comboKey, req }) - .then(() => { - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to update Payment Request form submission '${rebateId}' without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** NOTE: verifyMongoObjectId */ - if (mongoId && !ObjectId.isValid(mongoId)) { - const errorStatus = 400; - const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** Add custom metadata to track formio submissions from wrapper. */ - submission.metadata = { - ...submission.metadata, - ...formioCsbMetadata, - }; - - axiosFormio(req) - .put( - `${formioPaymentRequestFormUrl}/submission/${mongoId}`, - submission - ) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error updating Formio Payment Request form submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - const logMessage = `User with email '${mail}' attempted to update Payment Request form submission '${rebateId}' when the CSB Payment Request form enrollment period was closed.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 400; - const errorMessage = `CSB Payment Request form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- delete an existing Payment Request form submission from Formio -router.post( - "/delete-formio-payment-request-submission", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { mongoId, rebateId, comboKey } = body; - - // verify post data includes one of user's BAP combo keys - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to delete Payment Request form submission '${rebateId}' without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** - * ensure the BAP status of the corresponding Application form submission - * is "Edits Requested" before deleting the Payment Request form submission - * from Formio - */ - getBapFormSubmissionsStatuses(req, req.bapComboKeys) - .then((submissions) => { - const application = submissions.find((submission) => { - return ( - submission.Parent_Rebate_ID__c === rebateId && - submission.Record_Type_Name__c === "CSB Funding Request" - ); - }); - - const applicationNeedsEdits = - application?.Parent_CSB_Rebate__r.CSB_Funding_Request_Status__c === - "Edits Requested"; - - if (!applicationNeedsEdits) { - const errorStatus = 400; - const errorMessage = `Application form submission '${mongoId}' does not need edits.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - axiosFormio(req) - .delete(`${formioPaymentRequestFormUrl}/submission/${mongoId}`) - .then((axiosRes) => axiosRes.data) - .then((response) => { - const logMessage = `User with email '${mail}' successfully deleted Payment Request form submission '${rebateId}'.`; - log({ level: "info", message: logMessage, req }); - - res.json(response); - }) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error deleting Formio Payment Request form submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - // NOTE: logged in bap verifyBapConnection - const errorStatus = 500; - const errorMessage = `Error getting form submissions statuses from the BAP.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- get user's Close Out form submissions from Formio -router.get("/formio-close-out-submissions", storeBapComboKeys, (req, res) => { - const { bapComboKeys } = req; - - const userSubmissionsUrl = - `${formioCloseOutFormUrl}/submission` + - `?sort=-modified` + - `&limit=1000000` + - `&data.bap_hidden_entity_combo_key=${bapComboKeys.join( - "&data.bap_hidden_entity_combo_key=" - )}`; - - axiosFormio(req) - .get(userSubmissionsUrl) - .then((axiosRes) => axiosRes.data) - .then((submissions) => res.json(submissions)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Close Out form submissions.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- post a new Close Out form submission to Formio -router.post("/formio-close-out-submission", storeBapComboKeys, (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { - email, - title, - name, - entity, - comboKey, - rebateId, - applicationReviewItemId, - paymentRequestReviewItemId, - paymentRequestFormModified, - } = body; - - if (!closeOutFormOpen) { - const errorStatus = 400; - const errorMessage = `CSB Close Out form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to post a new Close Out form submission without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - const { - UNIQUE_ENTITY_ID__c, - ENTITY_EFT_INDICATOR__c, - ELEC_BUS_POC_EMAIL__c, - ALT_ELEC_BUS_POC_EMAIL__c, - GOVT_BUS_POC_EMAIL__c, - ALT_GOVT_BUS_POC_EMAIL__c, - } = entity; - - return getBapDataForCloseOut( - req, - applicationReviewItemId, - paymentRequestReviewItemId - ) - .then( - ({ - applicationRecordQuery, - paymentRequestRecordQuery, - busRecordsQuery, - }) => { - const { - Fleet_Name__c, - Fleet_Street_Address__c, - Fleet_City__c, - Fleet_State__c, - Fleet_Zip__c, - Fleet_Contact_Name__c, - Fleet_Contact_Title__c, - Fleet_Contact_Phone__c, - Fleet_Contact_Email__c, - School_District_Contact__r, - } = applicationRecordQuery[0]; - - const { - CSB_NCES_ID__c, - Primary_Applicant__r, - Alternate_Applicant__r, - Applicant_Organization__r, - CSB_School_District__r, - School_District_Prioritized__c, - Total_Rebate_Funds_Requested_PO__c, - Total_Bus_And_Infrastructure_Rebate__c, - Total_Infrastructure_Funds__c, - Num_Of_Buses_Requested_From_Application__c, - Total_Price_All_Buses__c, - Total_Bus_Rebate_Amount__c, - Total_All_Eligible_Infrastructure_Costs__c, - Total_Infrastructure_Rebate__c, - Total_Level_2_Charger_Costs__c, - Total_DC_Fast_Charger_Costs__c, - Total_Other_Infrastructure_Costs__c, - } = paymentRequestRecordQuery[0]; - - const busInfo = busRecordsQuery.map((record) => ({ - busNum: record.Rebate_Item_num__c, - oldBusNcesDistrictId: CSB_NCES_ID__c, - oldBusVin: record.CSB_VIN__c, - oldBusModelYear: record.CSB_Model_Year__c, - oldBusFuelType: record.CSB_Fuel_Type__c, - oldBusEstimatedRemainingLife: record.Old_Bus_Estimated_Remaining_Life__c, // prettier-ignore - oldBusExclude: record.Old_Bus_Exclude__c, - hidden_prf_oldBusExclude: record.Old_Bus_Exclude__c, - newBusDealer: record.Related_Line_Item__r?.Vendor_Name__c, - newBusFuelType: record.New_Bus_Fuel_Type__c, - hidden_prf_newBusFuelType: record.New_Bus_Fuel_Type__c, - newBusMake: record.New_Bus_Make__c, - hidden_prf_newBusMake: record.New_Bus_Make__c, - newBusMakeOther: record.CSB_Manufacturer_if_Other__c, - hidden_prf_newBusMakeOther: record.CSB_Manufacturer_if_Other__c, - newBusModel: record.New_Bus_Model__c, - hidden_prf_newBusModel: record.New_Bus_Model__c, - newBusModelYear: record.New_Bus_Model_Year__c, - hidden_prf_newBusModelYear: record.New_Bus_Model_Year__c, - newBusGvwr: record.New_Bus_GVWR__c, - hidden_prf_newBusGvwr: record.New_Bus_GVWR__c, - newBusPurchasePrice: record.New_Bus_Purchase_Price__c, - hidden_prf_newBusPurchasePrice: record.New_Bus_Purchase_Price__c, - hidden_prf_rebate: record.New_Bus_Rebate_Amount__c, - })); - - const submission = { - data: { - bap_hidden_entity_combo_key: comboKey, - hidden_prf_modified: paymentRequestFormModified, - hidden_current_user_email: email, - hidden_current_user_title: title, - hidden_current_user_name: name, - hidden_bap_rebate_id: rebateId, - hidden_sam_uei: UNIQUE_ENTITY_ID__c, - hidden_sam_efti: ENTITY_EFT_INDICATOR__c || "0000", - hidden_sam_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, - hidden_sam_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, - hidden_sam_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, - hidden_sam_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, - hidden_bap_district_id: CSB_NCES_ID__c, - hidden_bap_district_name: CSB_School_District__r?.Name, - hidden_bap_primary_fname: Primary_Applicant__r?.FirstName, - hidden_bap_primary_lname: Primary_Applicant__r?.LastName, - hidden_bap_primary_title: Primary_Applicant__r?.Title, - hidden_bap_primary_phone_number: Primary_Applicant__r?.Phone, - hidden_bap_primary_email: Primary_Applicant__r?.Email, - hidden_bap_alternate_fname: Alternate_Applicant__r?.FirstName || "", - hidden_bap_alternate_lname: Alternate_Applicant__r?.LastName || "", - hidden_bap_alternate_title: Alternate_Applicant__r?.Title || "", - hidden_bap_alternate_phone_number: Alternate_Applicant__r?.Phone || "", // prettier-ignore - hidden_bap_alternate_email: Alternate_Applicant__r?.Email || "", - hidden_bap_org_name: Applicant_Organization__r?.Name, - hidden_bap_fleet_name: Fleet_Name__c, - hidden_bap_fleet_address: Fleet_Street_Address__c, - hidden_bap_fleet_city: Fleet_City__c, - hidden_bap_fleet_state: Fleet_State__c, - hidden_bap_fleet_zip: Fleet_Zip__c, - hidden_bap_fleet_contact_name: Fleet_Contact_Name__c, - hidden_bap_fleet_contact_title: Fleet_Contact_Title__c, - hidden_bap_fleet_phone: Fleet_Contact_Phone__c, - hidden_bap_fleet_email: Fleet_Contact_Email__c, - hidden_bap_prioritized: School_District_Prioritized__c, - hidden_bap_requested_funds: Total_Rebate_Funds_Requested_PO__c, - hidden_bap_received_funds: Total_Bus_And_Infrastructure_Rebate__c, - hidden_bap_prf_infra_max_rebate: Total_Infrastructure_Funds__c, - hidden_bap_buses_requested_app: Num_Of_Buses_Requested_From_Application__c, // prettier-ignore - hidden_bap_total_bus_costs_prf: Total_Price_All_Buses__c, - hidden_bap_total_bus_rebate_received: Total_Bus_Rebate_Amount__c, - hidden_bap_total_infra_costs_prf: Total_All_Eligible_Infrastructure_Costs__c, // prettier-ignore - hidden_bap_total_infra_rebate_received: Total_Infrastructure_Rebate__c, // prettier-ignore - hidden_bap_total_infra_level2_charger: Total_Level_2_Charger_Costs__c, // prettier-ignore - hidden_bap_total_infra_dc_fast_charger: Total_DC_Fast_Charger_Costs__c, // prettier-ignore - hidden_bap_total_infra_other_costs: Total_Other_Infrastructure_Costs__c, // prettier-ignore - hidden_bap_district_contact_fname: School_District_Contact__r?.FirstName, // prettier-ignore - hidden_bap_district_contact_lname: School_District_Contact__r?.LastName, // prettier-ignore - busInfo, - }, - /** Add custom metadata to track formio submissions from wrapper. */ - metadata: { - ...formioCsbMetadata, - }, - state: "draft", - }; - - axiosFormio(req) - .post(`${formioCloseOutFormUrl}/submission`, submission) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error posting Formio Close Out form submission.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } - ) - .catch((error) => { - // NOTE: logged in bap verifyBapConnection - const errorStatus = 500; - const errorMessage = `Error getting data for a new Close Out form submission from the BAP.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); -}); - -// --- get an existing Close Out form's schema and submission data from Formio -router.get( - "/formio-close-out-submission/:rebateId", - storeBapComboKeys, - async (req, res) => { - const { bapComboKeys } = req; - const { mail } = req.user; - const { rebateId } = req.params; // CSB Rebate ID (6 digits) - - const matchedCloseOutFormSubmissions = - `${formioCloseOutFormUrl}/submission` + - `?data.hidden_bap_rebate_id=${rebateId}` + - `&select=_id,data.bap_hidden_entity_combo_key`; - - Promise.all([ - axiosFormio(req).get(matchedCloseOutFormSubmissions), - axiosFormio(req).get(formioCloseOutFormUrl), - ]) - .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) - .then(([submissions, schema]) => { - const submission = submissions[0]; - const mongoId = submission._id; - const comboKey = submission.data.bap_hidden_entity_combo_key; - - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to access Close Out form submission '${rebateId}' that they do not have access to.`; - log({ level: "warn", message: logMessage, req }); - - return res.json({ - userAccess: false, - formSchema: null, - submission: null, - }); - } - - /** NOTE: verifyMongoObjectId */ - if (mongoId && !ObjectId.isValid(mongoId)) { - const errorStatus = 400; - const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** - * NOTE: We can't just use the returned submission data here because - * Formio returns the string literal 'YES' instead of a base64 encoded - * image string for signature fields when you query for all submissions - * matching on a field's value (`/submission?data.hidden_bap_rebate_id=${rebateId}`). - * We need to query for a specific submission (e.g. `/submission/${mongoId}`), - * to have Formio return the correct signature field data. - */ - axiosFormio(req) - .get(`${formioCloseOutFormUrl}/submission/${mongoId}`) - .then((axiosRes) => axiosRes.data) - .then((submission) => { - return res.json({ - userAccess: true, - formSchema: { url: formioCloseOutFormUrl, json: schema }, - submission, - }); - }); - }) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting Formio Close Out form submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -// --- post an update to an existing draft Close Out form submission to Formio -router.post( - "/formio-close-out-submission/:rebateId", - storeBapComboKeys, - (req, res) => { - const { bapComboKeys, body } = req; - const { mail } = req.user; - const { rebateId } = req.params; // CSB Rebate ID (6 digits) - const { mongoId, submission } = body; - const comboKey = submission.data?.bap_hidden_entity_combo_key; - const formType = "close-out"; - - checkFormSubmissionPeriodAndBapStatus({ formType, mongoId, comboKey, req }) - .then(() => { - if (!bapComboKeys.includes(comboKey)) { - const logMessage = `User with email '${mail}' attempted to update Close Out form submission '${rebateId}' without a matching BAP combo key.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 401; - const errorMessage = `Unauthorized.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** NOTE: verifyMongoObjectId */ - if (mongoId && !ObjectId.isValid(mongoId)) { - const errorStatus = 400; - const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - /** Add custom metadata to track formio submissions from wrapper. */ - submission.metadata = { - ...submission.metadata, - ...formioCsbMetadata, - }; - - axiosFormio(req) - .put(`${formioCloseOutFormUrl}/submission/${mongoId}`, submission) - .then((axiosRes) => axiosRes.data) - .then((submission) => res.json(submission)) - .catch((error) => { - // NOTE: logged in axiosFormio response interceptor - const errorStatus = error.response?.status || 500; - const errorMessage = `Error updating Formio Close Out form submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - }) - .catch((error) => { - const logMessage = `User with email '${mail}' attempted to update Close Out form submission '${rebateId}' when the CSB Close Out form enrollment period was closed.`; - log({ level: "error", message: logMessage, req }); - - const errorStatus = 400; - const errorMessage = `CSB Close Out form enrollment period is closed.`; - return res.status(errorStatus).json({ message: errorMessage }); - }); - } -); - -module.exports = router; diff --git a/app/server/app/routes/bap.js b/app/server/app/routes/bap.js new file mode 100644 index 00000000..44c41bdf --- /dev/null +++ b/app/server/app/routes/bap.js @@ -0,0 +1,66 @@ +const express = require("express"); +// --- +const { ensureAuthenticated, storeBapComboKeys } = require("../middleware"); +const { + getSamEntities, + getBapFormSubmissionsStatuses, +} = require("../utilities/bap"); +const log = require("../utilities/logger"); + +const router = express.Router(); + +router.use(ensureAuthenticated); + +// --- get user's SAM.gov data from EPA's Business Automation Platform (BAP) +router.get("/sam", (req, res) => { + const { mail, memberof } = req.user; + const userRoles = memberof.split(","); + const adminOrHelpdeskUser = + userRoles.includes("csb_admin") || userRoles.includes("csb_helpdesk"); + + getSamEntities(req, mail) + .then((entities) => { + /** + * NOTE: allow admin or helpdesk users access to the app, even without + * SAM.gov data. + */ + if (!adminOrHelpdeskUser && entities?.length === 0) { + const logMessage = + `User with email '${mail}' tried to use app ` + + `without any associated SAM.gov records.`; + log({ level: "error", message: logMessage, req }); + + return res.json({ + results: false, + entities: [], + }); + } + + return res.json({ + results: true, + entities, + }); + }) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting SAM.gov data from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- get user's form submissions statuses from EPA's BAP +router.get("/submissions", storeBapComboKeys, (req, res) => { + const { bapComboKeys } = req; + + return getBapFormSubmissionsStatuses(req, bapComboKeys) + .then((submissions) => res.json(submissions)) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting form submissions statuses from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +module.exports = router; diff --git a/app/server/app/routes/config.js b/app/server/app/routes/config.js new file mode 100644 index 00000000..aa710ca6 --- /dev/null +++ b/app/server/app/routes/config.js @@ -0,0 +1,15 @@ +const express = require("express"); +// --- +const { submissionPeriodOpen } = require("../config/formio"); +const { ensureAuthenticated } = require("../middleware"); + +const router = express.Router(); + +router.use(ensureAuthenticated); + +// --- get CSB app specific configuration (form open enrollment status, etc.) +router.get("/", (req, res) => { + return res.json({ submissionPeriodOpen }); +}); + +module.exports = router; diff --git a/app/server/app/routes/content.js b/app/server/app/routes/content.js new file mode 100644 index 00000000..162492f5 --- /dev/null +++ b/app/server/app/routes/content.js @@ -0,0 +1,84 @@ +const { resolve } = require("node:path"); +const { readFile } = require("node:fs/promises"); +const express = require("express"); +const axios = require("axios").default || require("axios"); // TODO: https://github.com/axios/axios/issues/5011 +// --- +const log = require("../utilities/logger"); + +const { NODE_ENV, S3_PUBLIC_BUCKET, S3_PUBLIC_REGION } = process.env; + +const router = express.Router(); + +// --- get static content from S3 +router.get("/", (req, res) => { + /** NOTE: static content files found in `app/server/app/content/` directory. */ + const filenames = [ + "site-alert.md", + "helpdesk-intro.md", + "all-rebates-intro.md", + "all-rebates-outro.md", + "new-frf-dialog.md", + "draft-frf-intro.md", + "submitted-frf-intro.md", + "draft-prf-intro.md", + "submitted-prf-intro.md", + "draft-crf-intro.md", + "submitted-crf-intro.md", + ]; + + const s3BucketUrl = `https://${S3_PUBLIC_BUCKET}.s3-${S3_PUBLIC_REGION}.amazonaws.com`; + + Promise.all( + filenames.map((filename) => { + /** + * local development: read files directly from disk + * Cloud.gov: fetch files from the public s3 bucket + */ + return NODE_ENV === "development" + ? readFile(resolve(__dirname, "../content", filename), "utf8") + : axios.get(`${s3BucketUrl}/content/${filename}`); + }) + ) + .then((stringsOrResponses) => { + /** + * local development: no further processing of strings needed + * Cloud.gov: get data from responses + */ + return NODE_ENV === "development" + ? stringsOrResponses + : stringsOrResponses.map((axiosRes) => axiosRes.data); + }) + .then((data) => { + return res.json({ + siteAlert: data[0], + helpdeskIntro: data[1], + allRebatesIntro: data[2], + allRebatesOutro: data[3], + newFRFDialog: data[4], + draftFRFIntro: data[5], + submittedFRFIntro: data[6], + draftPRFIntro: data[7], + submittedPRFIntro: data[8], + draftCRFIntro: data[9], + submittedCRFIntro: data[10], + }); + }) + .catch((error) => { + if (typeof error.toJSON === "function") { + const logMessage = error.toJSON(); + log({ level: "debug", message: logMessage, req }); + } + + const errorStatus = error.response?.status || 500; + const errorMethod = error.response?.config?.method?.toUpperCase(); + const errorUrl = error.response?.config?.url; + + const logMessage = `S3 Error: ${errorStatus} ${errorMethod} ${errorUrl}`; + log({ level: "error", message: logMessage, req }); + + const errorMessage = `Error getting static content from S3 bucket.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +module.exports = router; diff --git a/app/server/app/routes/formio2022.js b/app/server/app/routes/formio2022.js new file mode 100644 index 00000000..af1052d1 --- /dev/null +++ b/app/server/app/routes/formio2022.js @@ -0,0 +1,770 @@ +const express = require("express"); +const ObjectId = require("mongodb").ObjectId; +// --- +const { + axiosFormio, + formUrl, + submissionPeriodOpen, + formioCSBMetadata, +} = require("../config/formio"); +const { + ensureAuthenticated, + storeBapComboKeys, + verifyMongoObjectId, +} = require("../middleware"); +const { + getBapFormSubmissionsStatuses, + getBapDataForPRF, + getBapDataForCRF, + checkFormSubmissionPeriodAndBapStatus, +} = require("../utilities/bap"); +const { + uploadS3FileMetadata, + downloadS3FileMetadata, + fetchFRFSubmissions, + createFRFSubmission, + fetchFRFSubmission, + updateFRFSubmission, +} = require("../utilities/formio"); +const log = require("../utilities/logger"); + +const formioPRFUrl = formUrl["2022"].prf; +const formioCRFUrl = formUrl["2022"].crf; + +const router = express.Router(); + +router.use(ensureAuthenticated); + +// --- download Formio S3 file metadata +router.get( + "/s3/:formType/:mongoId/:comboKey/storage/s3", + storeBapComboKeys, + (req, res) => { + downloadS3FileMetadata({ rebateYear: "2022", req, res }); + } +); + +// --- upload Formio S3 file metadata +router.post( + "/s3/:formType/:mongoId/:comboKey/storage/s3", + storeBapComboKeys, + (req, res) => { + uploadS3FileMetadata({ rebateYear: "2022", req, res }); + } +); + +// --- get user's 2022 FRF submissions from Formio +router.get("/frf-submissions", storeBapComboKeys, (req, res) => { + fetchFRFSubmissions({ rebateYear: "2022", req, res }); +}); + +// --- post a new 2022 FRF submission to Formio +router.post("/frf-submission", storeBapComboKeys, (req, res) => { + createFRFSubmission({ rebateYear: "2022", req, res }); +}); + +// --- get an existing 2022 FRF's schema and submission data from Formio +router.get( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + fetchFRFSubmission({ rebateYear: "2022", req, res }); + } +); + +// --- post an update to an existing draft 2022 FRF submission to Formio +router.post( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + updateFRFSubmission({ rebateYear: "2022", req, res }); + } +); + +// --- get user's 2022 PRF submissions from Formio +router.get("/prf-submissions", storeBapComboKeys, (req, res) => { + const { bapComboKeys } = req; + + const submissionsUrl = + `${formioPRFUrl}/submission` + + `?sort=-modified` + + `&limit=1000000` + + `&data.bap_hidden_entity_combo_key=${bapComboKeys.join( + "&data.bap_hidden_entity_combo_key=" + )}`; + + axiosFormio(req) + .get(submissionsUrl) + .then((axiosRes) => axiosRes.data) + .then((submissions) => res.json(submissions)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio Payment Request form submissions.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- post a new 2022 PRF submission to Formio +router.post("/prf-submission", storeBapComboKeys, (req, res) => { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { + email, + title, + name, + entity, + comboKey, + rebateId, + frfReviewItemId, + frfFormModified, + } = body; + + if (!submissionPeriodOpen["2022"].prf) { + const errorStatus = 400; + const errorMessage = `CSB Payment Request form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to post a new PRF submission ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const { + UNIQUE_ENTITY_ID__c, + ENTITY_EFT_INDICATOR__c, + ELEC_BUS_POC_EMAIL__c, + ALT_ELEC_BUS_POC_EMAIL__c, + GOVT_BUS_POC_EMAIL__c, + ALT_GOVT_BUS_POC_EMAIL__c, + } = entity; + + return getBapDataForPRF(req, frfReviewItemId) + .then(({ frfRecordQuery, busRecordsQuery }) => { + const { + CSB_NCES_ID__c, + Primary_Applicant__r, + Alternate_Applicant__r, + Applicant_Organization__r, + CSB_School_District__r, + Fleet_Name__c, + School_District_Prioritized__c, + Total_Rebate_Funds_Requested__c, + Total_Infrastructure_Funds__c, + } = frfRecordQuery[0]; + + const busInfo = busRecordsQuery.map((record) => ({ + busNum: record.Rebate_Item_num__c, + oldBusNcesDistrictId: CSB_NCES_ID__c, + oldBusVin: record.CSB_VIN__c, + oldBusModelYear: record.CSB_Model_Year__c, + oldBusFuelType: record.CSB_Fuel_Type__c, + newBusFuelType: record.CSB_Replacement_Fuel_Type__c, + hidden_bap_max_rebate: record.CSB_Funds_Requested__c, + })); + + /** + * NOTE: `purchaseOrders` is initialized as an empty array to fix some + * issue with the field being changed to an object when the form loads + */ + const submission = { + data: { + bap_hidden_entity_combo_key: comboKey, + hidden_application_form_modified: frfFormModified, + hidden_current_user_email: email, + hidden_current_user_title: title, + hidden_current_user_name: name, + hidden_sam_uei: UNIQUE_ENTITY_ID__c, + hidden_sam_efti: ENTITY_EFT_INDICATOR__c || "0000", + hidden_sam_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, + hidden_sam_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, + hidden_sam_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, + hidden_sam_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, + hidden_bap_rebate_id: rebateId, + hidden_bap_district_id: CSB_NCES_ID__c, + hidden_bap_primary_name: Primary_Applicant__r?.Name, + hidden_bap_primary_title: Primary_Applicant__r?.Title, + hidden_bap_primary_phone_number: Primary_Applicant__r?.Phone, + hidden_bap_primary_email: Primary_Applicant__r?.Email, + hidden_bap_alternate_name: Alternate_Applicant__r?.Name || "", + hidden_bap_alternate_title: Alternate_Applicant__r?.Title || "", + hidden_bap_alternate_phone_number: Alternate_Applicant__r?.Phone || "", // prettier-ignore + hidden_bap_alternate_email: Alternate_Applicant__r?.Email || "", + hidden_bap_org_name: Applicant_Organization__r?.Name, + hidden_bap_district_name: CSB_School_District__r?.Name, + hidden_bap_fleet_name: Fleet_Name__c, + hidden_bap_prioritized: School_District_Prioritized__c, + hidden_bap_requested_funds: Total_Rebate_Funds_Requested__c, + hidden_bap_infra_max_rebate: Total_Infrastructure_Funds__c, + busInfo, + purchaseOrders: [], + }, + /** Add custom metadata to track formio submissions from wrapper. */ + metadata: { + ...formioCSBMetadata, + }, + state: "draft", + }; + + axiosFormio(req) + .post(`${formioPRFUrl}/submission`, submission) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error posting Formio Payment Request form submission.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting data for a new Payment Request form submission from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- get an existing 2022 PRF's schema and submission data from Formio +router.get("/prf-submission/:rebateId", storeBapComboKeys, async (req, res) => { + const { bapComboKeys } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + + const matchedPRFSubmissions = + `${formioPRFUrl}/submission` + + `?data.hidden_bap_rebate_id=${rebateId}` + + `&select=_id,data.bap_hidden_entity_combo_key`; + + Promise.all([ + axiosFormio(req).get(matchedPRFSubmissions), + axiosFormio(req).get(formioPRFUrl), + ]) + .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) + .then(([submissions, schema]) => { + const submission = submissions[0]; + const mongoId = submission._id; + const comboKey = submission.data.bap_hidden_entity_combo_key; + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to access PRF submission '${rebateId}' ` + + `that they do not have access to.`; + log({ level: "warn", message: logMessage, req }); + + return res.json({ + userAccess: false, + formSchema: null, + submission: null, + }); + } + + /** NOTE: verifyMongoObjectId */ + if (mongoId && !ObjectId.isValid(mongoId)) { + const errorStatus = 400; + const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** + * NOTE: We can't just use the returned submission data here because + * Formio returns the string literal 'YES' instead of a base64 encoded + * image string for signature fields when you query for all submissions + * matching on a field's value (`/submission?data.hidden_bap_rebate_id=${rebateId}`). + * We need to query for a specific submission (e.g. `/submission/${mongoId}`), + * to have Formio return the correct signature field data. + */ + axiosFormio(req) + .get(`${formioPRFUrl}/submission/${mongoId}`) + .then((axiosRes) => axiosRes.data) + .then((submission) => { + return res.json({ + userAccess: true, + formSchema: { url: formioPRFUrl, json: schema }, + submission, + }); + }); + }) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio Payment Request form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- post an update to an existing draft 2022 PRF submission to Formio +router.post("/prf-submission/:rebateId", storeBapComboKeys, (req, res) => { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + const { mongoId, submission } = body; + const comboKey = submission.data?.bap_hidden_entity_combo_key; + + checkFormSubmissionPeriodAndBapStatus({ + rebateYear: "2022", + formType: "prf", + mongoId, + comboKey, + req, + }) + .then(() => { + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to update PRF submission '${rebateId}' ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** NOTE: verifyMongoObjectId */ + if (mongoId && !ObjectId.isValid(mongoId)) { + const errorStatus = 400; + const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** Add custom metadata to track formio submissions from wrapper. */ + submission.metadata = { + ...submission.metadata, + ...formioCSBMetadata, + }; + + axiosFormio(req) + .put(`${formioPRFUrl}/submission/${mongoId}`, submission) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error updating Formio Payment Request form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + const logMessage = + `User with email '${mail}' attempted to update PRF submission '${rebateId}' ` + + `when the CSB PRF enrollment period was closed.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 400; + const errorMessage = `CSB Payment Request form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- delete an existing 2022 PRF submission from Formio +router.post("/delete-prf-submission", storeBapComboKeys, (req, res) => { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { mongoId, rebateId, comboKey } = body; + + // verify post data includes one of user's BAP combo keys + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to delete PRF submission '${rebateId}' ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** + * ensure the BAP status of the corresponding FRF submission is "Edits + * Requested" before deleting the FRF submission from Formio + */ + getBapFormSubmissionsStatuses(req, req.bapComboKeys) + .then((submissions) => { + const frf = submissions.find((submission) => { + return ( + submission.Parent_Rebate_ID__c === rebateId && + submission.Record_Type_Name__c === "CSB Funding Request" + ); + }); + + const frfNeedsEdits = + frf?.Parent_CSB_Rebate__r.CSB_Funding_Request_Status__c === + "Edits Requested"; + + if (!frfNeedsEdits) { + const errorStatus = 400; + const errorMessage = `Application form submission '${mongoId}' does not need edits.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + axiosFormio(req) + .delete(`${formioPRFUrl}/submission/${mongoId}`) + .then((axiosRes) => axiosRes.data) + .then((response) => { + const logMessage = `User with email '${mail}' successfully deleted PRF submission '${rebateId}'.`; + log({ level: "info", message: logMessage, req }); + + res.json(response); + }) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error deleting Formio Payment Request form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting form submissions statuses from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- get user's 2022 CRF submissions from Formio +router.get("/crf-submissions", storeBapComboKeys, (req, res) => { + const { bapComboKeys } = req; + + const submissionsUrl = + `${formioCRFUrl}/submission` + + `?sort=-modified` + + `&limit=1000000` + + `&data.bap_hidden_entity_combo_key=${bapComboKeys.join( + "&data.bap_hidden_entity_combo_key=" + )}`; + + axiosFormio(req) + .get(submissionsUrl) + .then((axiosRes) => axiosRes.data) + .then((submissions) => res.json(submissions)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio Close Out form submissions.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- post a new 2022 CRF submission to Formio +router.post("/crf-submission", storeBapComboKeys, (req, res) => { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { + email, + title, + name, + entity, + comboKey, + rebateId, + frfReviewItemId, + prfReviewItemId, + prfModified, + } = body; + + if (!submissionPeriodOpen["2022"].crf) { + const errorStatus = 400; + const errorMessage = `CSB Close Out form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to post a new CRF submission ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const { + UNIQUE_ENTITY_ID__c, + ENTITY_EFT_INDICATOR__c, + ELEC_BUS_POC_EMAIL__c, + ALT_ELEC_BUS_POC_EMAIL__c, + GOVT_BUS_POC_EMAIL__c, + ALT_GOVT_BUS_POC_EMAIL__c, + } = entity; + + return getBapDataForCRF(req, frfReviewItemId, prfReviewItemId) + .then(({ frfRecordQuery, prfRecordQuery, busRecordsQuery }) => { + const { + Fleet_Name__c, + Fleet_Street_Address__c, + Fleet_City__c, + Fleet_State__c, + Fleet_Zip__c, + Fleet_Contact_Name__c, + Fleet_Contact_Title__c, + Fleet_Contact_Phone__c, + Fleet_Contact_Email__c, + School_District_Contact__r, + } = frfRecordQuery[0]; + + const { + CSB_NCES_ID__c, + Primary_Applicant__r, + Alternate_Applicant__r, + Applicant_Organization__r, + CSB_School_District__r, + School_District_Prioritized__c, + Total_Rebate_Funds_Requested_PO__c, + Total_Bus_And_Infrastructure_Rebate__c, + Total_Infrastructure_Funds__c, + Num_Of_Buses_Requested_From_Application__c, + Total_Price_All_Buses__c, + Total_Bus_Rebate_Amount__c, + Total_All_Eligible_Infrastructure_Costs__c, + Total_Infrastructure_Rebate__c, + Total_Level_2_Charger_Costs__c, + Total_DC_Fast_Charger_Costs__c, + Total_Other_Infrastructure_Costs__c, + } = prfRecordQuery[0]; + + const busInfo = busRecordsQuery.map((record) => ({ + busNum: record.Rebate_Item_num__c, + oldBusNcesDistrictId: CSB_NCES_ID__c, + oldBusVin: record.CSB_VIN__c, + oldBusModelYear: record.CSB_Model_Year__c, + oldBusFuelType: record.CSB_Fuel_Type__c, + oldBusEstimatedRemainingLife: record.Old_Bus_Estimated_Remaining_Life__c, // prettier-ignore + oldBusExclude: record.Old_Bus_Exclude__c, + hidden_prf_oldBusExclude: record.Old_Bus_Exclude__c, + newBusDealer: record.Related_Line_Item__r?.Vendor_Name__c, + newBusFuelType: record.New_Bus_Fuel_Type__c, + hidden_prf_newBusFuelType: record.New_Bus_Fuel_Type__c, + newBusMake: record.New_Bus_Make__c, + hidden_prf_newBusMake: record.New_Bus_Make__c, + newBusMakeOther: record.CSB_Manufacturer_if_Other__c, + hidden_prf_newBusMakeOther: record.CSB_Manufacturer_if_Other__c, + newBusModel: record.New_Bus_Model__c, + hidden_prf_newBusModel: record.New_Bus_Model__c, + newBusModelYear: record.New_Bus_Model_Year__c, + hidden_prf_newBusModelYear: record.New_Bus_Model_Year__c, + newBusGvwr: record.New_Bus_GVWR__c, + hidden_prf_newBusGvwr: record.New_Bus_GVWR__c, + newBusPurchasePrice: record.New_Bus_Purchase_Price__c, + hidden_prf_newBusPurchasePrice: record.New_Bus_Purchase_Price__c, + hidden_prf_rebate: record.New_Bus_Rebate_Amount__c, + })); + + const submission = { + data: { + bap_hidden_entity_combo_key: comboKey, + hidden_prf_modified: prfModified, + hidden_current_user_email: email, + hidden_current_user_title: title, + hidden_current_user_name: name, + hidden_bap_rebate_id: rebateId, + hidden_sam_uei: UNIQUE_ENTITY_ID__c, + hidden_sam_efti: ENTITY_EFT_INDICATOR__c || "0000", + hidden_sam_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, + hidden_sam_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, + hidden_sam_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, + hidden_sam_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, + hidden_bap_district_id: CSB_NCES_ID__c, + hidden_bap_district_name: CSB_School_District__r?.Name, + hidden_bap_primary_fname: Primary_Applicant__r?.FirstName, + hidden_bap_primary_lname: Primary_Applicant__r?.LastName, + hidden_bap_primary_title: Primary_Applicant__r?.Title, + hidden_bap_primary_phone_number: Primary_Applicant__r?.Phone, + hidden_bap_primary_email: Primary_Applicant__r?.Email, + hidden_bap_alternate_fname: Alternate_Applicant__r?.FirstName || "", + hidden_bap_alternate_lname: Alternate_Applicant__r?.LastName || "", + hidden_bap_alternate_title: Alternate_Applicant__r?.Title || "", + hidden_bap_alternate_phone_number: Alternate_Applicant__r?.Phone || "", // prettier-ignore + hidden_bap_alternate_email: Alternate_Applicant__r?.Email || "", + hidden_bap_org_name: Applicant_Organization__r?.Name, + hidden_bap_fleet_name: Fleet_Name__c, + hidden_bap_fleet_address: Fleet_Street_Address__c, + hidden_bap_fleet_city: Fleet_City__c, + hidden_bap_fleet_state: Fleet_State__c, + hidden_bap_fleet_zip: Fleet_Zip__c, + hidden_bap_fleet_contact_name: Fleet_Contact_Name__c, + hidden_bap_fleet_contact_title: Fleet_Contact_Title__c, + hidden_bap_fleet_phone: Fleet_Contact_Phone__c, + hidden_bap_fleet_email: Fleet_Contact_Email__c, + hidden_bap_prioritized: School_District_Prioritized__c, + hidden_bap_requested_funds: Total_Rebate_Funds_Requested_PO__c, + hidden_bap_received_funds: Total_Bus_And_Infrastructure_Rebate__c, + hidden_bap_prf_infra_max_rebate: Total_Infrastructure_Funds__c, + hidden_bap_buses_requested_app: Num_Of_Buses_Requested_From_Application__c, // prettier-ignore + hidden_bap_total_bus_costs_prf: Total_Price_All_Buses__c, + hidden_bap_total_bus_rebate_received: Total_Bus_Rebate_Amount__c, + hidden_bap_total_infra_costs_prf: Total_All_Eligible_Infrastructure_Costs__c, // prettier-ignore + hidden_bap_total_infra_rebate_received: Total_Infrastructure_Rebate__c, // prettier-ignore + hidden_bap_total_infra_level2_charger: Total_Level_2_Charger_Costs__c, + hidden_bap_total_infra_dc_fast_charger: Total_DC_Fast_Charger_Costs__c, // prettier-ignore + hidden_bap_total_infra_other_costs: Total_Other_Infrastructure_Costs__c, // prettier-ignore + hidden_bap_district_contact_fname: School_District_Contact__r?.FirstName, // prettier-ignore + hidden_bap_district_contact_lname: School_District_Contact__r?.LastName, // prettier-ignore + busInfo, + }, + /** Add custom metadata to track formio submissions from wrapper. */ + metadata: { + ...formioCSBMetadata, + }, + state: "draft", + }; + + axiosFormio(req) + .post(`${formioCRFUrl}/submission`, submission) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error posting Formio Close Out form submission.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting data for a new Close Out form submission from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- get an existing 2022 CRF's schema and submission data from Formio +router.get("/crf-submission/:rebateId", storeBapComboKeys, async (req, res) => { + const { bapComboKeys } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + + const matchedCRFSubmissions = + `${formioCRFUrl}/submission` + + `?data.hidden_bap_rebate_id=${rebateId}` + + `&select=_id,data.bap_hidden_entity_combo_key`; + + Promise.all([ + axiosFormio(req).get(matchedCRFSubmissions), + axiosFormio(req).get(formioCRFUrl), + ]) + .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) + .then(([submissions, schema]) => { + const submission = submissions[0]; + const mongoId = submission._id; + const comboKey = submission.data.bap_hidden_entity_combo_key; + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to access CRF submission '${rebateId}' ` + + `that they do not have access to.`; + log({ level: "warn", message: logMessage, req }); + + return res.json({ + userAccess: false, + formSchema: null, + submission: null, + }); + } + + /** NOTE: verifyMongoObjectId */ + if (mongoId && !ObjectId.isValid(mongoId)) { + const errorStatus = 400; + const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** + * NOTE: We can't just use the returned submission data here because + * Formio returns the string literal 'YES' instead of a base64 encoded + * image string for signature fields when you query for all submissions + * matching on a field's value (`/submission?data.hidden_bap_rebate_id=${rebateId}`). + * We need to query for a specific submission (e.g. `/submission/${mongoId}`), + * to have Formio return the correct signature field data. + */ + axiosFormio(req) + .get(`${formioCRFUrl}/submission/${mongoId}`) + .then((axiosRes) => axiosRes.data) + .then((submission) => { + return res.json({ + userAccess: true, + formSchema: { url: formioCRFUrl, json: schema }, + submission, + }); + }); + }) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio Close Out form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +// --- post an update to an existing draft 2022 CRF submission to Formio +router.post("/crf-submission/:rebateId", storeBapComboKeys, (req, res) => { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + const { mongoId, submission } = body; + const comboKey = submission.data?.bap_hidden_entity_combo_key; + + checkFormSubmissionPeriodAndBapStatus({ + rebateYear: "2022", + formType: "crf", + mongoId, + comboKey, + req, + }) + .then(() => { + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to update CRF submission '${rebateId}' ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** NOTE: verifyMongoObjectId */ + if (mongoId && !ObjectId.isValid(mongoId)) { + const errorStatus = 400; + const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** Add custom metadata to track formio submissions from wrapper. */ + submission.metadata = { + ...submission.metadata, + ...formioCSBMetadata, + }; + + axiosFormio(req) + .put(`${formioCRFUrl}/submission/${mongoId}`, submission) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error updating Formio Close Out form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + const logMessage = + `User with email '${mail}' attempted to update CRF submission '${rebateId}' ` + + `when the CSB CRF enrollment period was closed.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 400; + const errorMessage = `CSB Close Out form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + +module.exports = router; diff --git a/app/server/app/routes/formio2023.js b/app/server/app/routes/formio2023.js new file mode 100644 index 00000000..7c5b442f --- /dev/null +++ b/app/server/app/routes/formio2023.js @@ -0,0 +1,83 @@ +const express = require("express"); +const ObjectId = require("mongodb").ObjectId; +// --- +const { + axiosFormio, + formUrl, + submissionPeriodOpen, + formioCSBMetadata, +} = require("../config/formio"); +const { + ensureAuthenticated, + storeBapComboKeys, + verifyMongoObjectId, +} = require("../middleware"); +const { + getBapFormSubmissionsStatuses, + getBapDataForPRF, + getBapDataForCRF, + checkFormSubmissionPeriodAndBapStatus, +} = require("../utilities/bap"); +const { + uploadS3FileMetadata, + downloadS3FileMetadata, + fetchFRFSubmissions, + createFRFSubmission, + fetchFRFSubmission, + updateFRFSubmission, +} = require("../utilities/formio"); +const log = require("../utilities/logger"); + +const router = express.Router(); + +router.use(ensureAuthenticated); + +// --- download Formio S3 file metadata +router.get( + "/s3/:formType/:mongoId/:comboKey/storage/s3", + storeBapComboKeys, + (req, res) => { + downloadS3FileMetadata({ rebateYear: "2023", req, res }); + } +); + +// --- upload Formio S3 file metadata +router.post( + "/s3/:formType/:mongoId/:comboKey/storage/s3", + storeBapComboKeys, + (req, res) => { + uploadS3FileMetadata({ rebateYear: "2023", req, res }); + } +); + +// --- get user's 2023 FRF submissions from Formio +router.get("/frf-submissions", storeBapComboKeys, (req, res) => { + fetchFRFSubmissions({ rebateYear: "2023", req, res }); +}); + +// --- post a new 2023 FRF submission to Formio +router.post("/frf-submission", storeBapComboKeys, (req, res) => { + createFRFSubmission({ rebateYear: "2023", req, res }); +}); + +// --- get an existing 2023 FRF's schema and submission data from Formio +router.get( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + fetchFRFSubmission({ rebateYear: "2023", req, res }); + } +); + +// --- post an update to an existing draft 2023 FRF submission to Formio +router.post( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + updateFRFSubmission({ rebateYear: "2023", req, res }); + } +); + +module.exports = router; diff --git a/app/server/app/routes/help.js b/app/server/app/routes/help.js index a501a58b..79793c49 100644 --- a/app/server/app/routes/help.js +++ b/app/server/app/routes/help.js @@ -1,12 +1,7 @@ const express = require("express"); const ObjectId = require("mongodb").ObjectId; // --- -const { - axiosFormio, - formioApplicationFormUrl, - formioPaymentRequestFormUrl, - formioCloseOutFormUrl, -} = require("../config/formio"); +const { axiosFormio, formUrl } = require("../config/formio"); const { ensureAuthenticated, ensureHelpdesk } = require("../middleware"); const { getBapFormSubmissionData } = require("../utilities/bap"); @@ -17,7 +12,7 @@ router.use(ensureAuthenticated); router.use(ensureHelpdesk); // --- get an existing form's submission data from Formio -router.get("/formio-submission/:formType/:id", (req, res) => { +router.get("/formio/submission/:formType/:id", (req, res) => { const { formType, id } = req.params; const rebateId = id.length === 6 ? id : null; @@ -31,29 +26,22 @@ router.get("/formio-submission/:formType/:id", (req, res) => { } const formName = - formType === "application" + formType === "frf" ? "CSB Application" - : formType === "payment-request" + : formType === "prf" ? "CSB Payment Request" - : formType === "close-out" + : formType === "crf" ? "CSB Close Out" : "CSB"; - const formUrl = - formType === "application" - ? formioApplicationFormUrl - : formType === "payment-request" - ? formioPaymentRequestFormUrl - : formType === "close-out" - ? formioCloseOutFormUrl - : null; // fallback + const formioFormUrl = formUrl["2022"][formType]; return getBapFormSubmissionData(req, formType, rebateId, mongoId).then( (bapSubmission) => { - if (!bapSubmission || !formUrl) { + if (!bapSubmission || !formioFormUrl) { const logId = rebateId || mongoId; const errorStatus = 500; - const errorMessage = `Error getting ${formName} form submission '${logId}' from the BAP.`; + const errorMessage = `Error getting ${formName} submission '${logId}' from the BAP.`; return res.status(errorStatus).json({ message: errorMessage }); } @@ -68,13 +56,13 @@ router.get("/formio-submission/:formType/:id", (req, res) => { } = bapSubmission; return Promise.all([ - axiosFormio(req).get(`${formUrl}/submission/${CSB_Form_ID__c}`), - axiosFormio(req).get(formUrl), + axiosFormio(req).get(`${formioFormUrl}/submission/${CSB_Form_ID__c}`), + axiosFormio(req).get(formioFormUrl), ]) .then((responses) => responses.map((axiosRes) => axiosRes.data)) .then(([formioSubmission, schema]) => { return res.json({ - formSchema: { url: formUrl, json: schema }, + formSchema: { url: formioFormUrl, json: schema }, formio: formioSubmission, bap: { modified: CSB_Modified_Full_String__c, // ISO 8601 date time string @@ -94,9 +82,9 @@ router.get("/formio-submission/:formType/:id", (req, res) => { }); }) .catch((error) => { - // NOTE: logged in axiosFormio response interceptor + // NOTE: error is logged in axiosFormio response interceptor const errorStatus = error.response?.status || 500; - const errorMessage = `Error getting ${formName} form submission '${CSB_Form_ID__c}'.`; + const errorMessage = `Error getting ${formName} submission '${CSB_Form_ID__c}'.`; return res.status(errorStatus).json({ message: errorMessage }); }); } diff --git a/app/server/app/routes/index.js b/app/server/app/routes/index.js index 1390620d..edb1585b 100644 --- a/app/server/app/routes/index.js +++ b/app/server/app/routes/index.js @@ -3,8 +3,13 @@ const express = require("express"); const router = express.Router(); router.use("/", require("./auth")); -router.use("/api", require("./api")); -router.use("/help", require("./help")); +router.use("/api/content", require("./content")); +router.use("/api/user", require("./user")); +router.use("/api/config", require("./config")); +router.use("/api/bap", require("./bap")); +router.use("/api/formio/2022", require("./formio2022")); +router.use("/api/formio/2023", require("./formio2023")); +router.use("/api/help", require("./help")); router.use("/status", require("./status")); module.exports = router; diff --git a/app/server/app/routes/status.js b/app/server/app/routes/status.js index 195f70fd..cb1f0ffd 100644 --- a/app/server/app/routes/status.js +++ b/app/server/app/routes/status.js @@ -1,11 +1,6 @@ const express = require("express"); // --- -const { - axiosFormio, - formioApplicationFormUrl, - formioPaymentRequestFormUrl, - formioCloseOutFormUrl, -} = require("../config/formio"); +const { axiosFormio, formUrl } = require("../config/formio"); const { getSamEntities } = require("../utilities/bap"); const router = express.Router(); @@ -31,7 +26,7 @@ router.get("/bap-sam-data", (req, res) => { router.get("/formio-application-schema", (req, res) => { axiosFormio(req) - .get(formioApplicationFormUrl) + .get(formUrl["2022"].frf) .then((axiosRes) => axiosRes.data) .then((schema) => { /** @@ -41,14 +36,14 @@ router.get("/formio-application-schema", (req, res) => { return res.json({ status: schema.type === "form" && !!schema.title }); }) .catch((error) => { - // NOTE: logged in axiosFormio response interceptor + // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); }); router.get("/formio-payment-request-schema", (req, res) => { axiosFormio(req) - .get(formioPaymentRequestFormUrl) + .get(formUrl["2022"].prf) .then((axiosRes) => axiosRes.data) .then((schema) => { /** @@ -58,14 +53,14 @@ router.get("/formio-payment-request-schema", (req, res) => { return res.json({ status: schema.type === "form" && !!schema.title }); }) .catch((error) => { - // NOTE: logged in axiosFormio response interceptor + // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); }); router.get("/formio-close-out-schema", (req, res) => { axiosFormio(req) - .get(formioCloseOutFormUrl) + .get(formUrl["2022"].crf) .then((axiosRes) => axiosRes.data) .then((schema) => { /** @@ -75,7 +70,7 @@ router.get("/formio-close-out-schema", (req, res) => { return res.json({ status: schema.type === "form" && !!schema.title }); }) .catch((error) => { - // NOTE: logged in axiosFormio response interceptor + // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); }); diff --git a/app/server/app/routes/user.js b/app/server/app/routes/user.js new file mode 100644 index 00000000..561ad247 --- /dev/null +++ b/app/server/app/routes/user.js @@ -0,0 +1,15 @@ +const express = require("express"); +// --- +const { ensureAuthenticated } = require("../middleware"); + +const router = express.Router(); + +router.use(ensureAuthenticated); + +// --- get user data from EPA Gateway/Login.gov +router.get("/", (req, res) => { + const { mail, memberof, exp } = req.user; + return res.json({ mail, memberof, exp }); +}); + +module.exports = router; diff --git a/app/server/app/utilities/bap.js b/app/server/app/utilities/bap.js index 1070e82f..a53b9573 100644 --- a/app/server/app/utilities/bap.js +++ b/app/server/app/utilities/bap.js @@ -3,6 +3,8 @@ const jsforce = require("jsforce"); const express = require("express"); const log = require("../utilities/logger"); +// --- +const { submissionPeriodOpen } = require("../config/formio"); /** * @typedef {Object} BapSamEntity @@ -55,7 +57,7 @@ const log = require("../utilities/logger"); */ /** - * @typedef {Object} BapDataForPaymentRequest + * @typedef {Object} BapDataForPRF * @property {{ * Id: string * UEI_EFTI_Combo_Key__c: string @@ -82,7 +84,7 @@ const log = require("../utilities/logger"); * School_District_Prioritized__c: string * Total_Rebate_Funds_Requested__c: string * Total_Infrastructure_Funds__c: string - * }[]} applicationRecordQuery + * }[]} frfRecordQuery * @property {{ * Rebate_Item_num__c: string * CSB_VIN__c: string @@ -98,7 +100,7 @@ const log = require("../utilities/logger"); */ /** - * @typedef {Object} BapDataForForCloseOut + * @typedef {Object} BapDataForForCRF * @property {{ * Fleet_Name__c: string * Fleet_Street_Address__c: string @@ -113,7 +115,7 @@ const log = require("../utilities/logger"); * FirstName: string * LastName: string * } - * }[]} applicationRecordQuery + * }[]} frfRecordQuery * @property {{ * Id: string * UEI_EFTI_Combo_Key__c: string @@ -150,7 +152,7 @@ const log = require("../utilities/logger"); * Total_Level_2_Charger_Costs__c: string * Total_DC_Fast_Charger_Costs__c: string * Total_Other_Infrastructure_Costs__c: string - * }[]} paymentRequestRecordQuery + * }[]} prfRecordQuery * @property {{ * Rebate_Item_num__c: string * CSB_VIN__c: string @@ -313,34 +315,27 @@ async function queryForSamEntities(req, email) { * statuses and related metadata. * * @param {express.Request} req - * @param {'application' | 'payment-request' | 'close-out'} formType + * @param {'frf' | 'prf' | 'crf'} formType * @param {string | null} rebateId * @param {string | null} mongoId * @returns {Promise} fields associated a form submission */ async function queryForBapFormSubmissionData(req, formType, rebateId, mongoId) { - const formName = - formType === "application" - ? "CSB Application" - : formType === "payment-request" - ? "CSB Payment Request" - : formType === "close-out" - ? "CSB Close Out" - : "CSB"; - const logId = rebateId ? `rebateId: '${rebateId}'` : `mongoId: '${mongoId}'`; - const logMessage = `Querying the BAP for ${formName} form submission data associated with ${logId}.`; + const logMessage = + `Querying the BAP for ${formType.toUpperCase()} submission data ` + + `associated with ${logId}.`; log({ level: "info", message: logMessage }); /** @type {jsforce.Connection} */ const { bapConnection } = req.app.locals; const developername = - formType === "application" + formType === "frf" ? "CSB_Funding_Request" - : formType === "payment-request" + : formType === "prf" ? "CSB_Payment_Request" - : formType === "close-out" + : formType === "crf" ? "CSB_Closeout_Request" : null; // fallback @@ -423,7 +418,9 @@ async function queryForBapFormSubmissionData(req, formType, rebateId, mongoId) { * @returns {Promise} collection of fields associated with each form submission */ async function queryForBapFormSubmissionsStatuses(req, comboKeys) { - const logMessage = `Querying the BAP for form submissions statuses associated with combokeys: '${comboKeys}'.`; + const logMessage = + `Querying the BAP for form submissions statuses associated with ` + + `combokeys: '${comboKeys}'.`; log({ level: "info", message: logMessage }); /** @type {jsforce.Connection} */ @@ -507,17 +504,17 @@ async function queryForBapFormSubmissionsStatuses(req, comboKeys) { } /** - * Uses cached JSforce connection to query the BAP for Application form - * submission data, for use in a brand new Payment Request form submission. + * Uses cached JSforce connection to query the BAP for FRF submission data, for + * use in a brand new PRF submission. * * @param {express.Request} req - * @param {string} applicationReviewItemId CSB Rebate ID with the form/version ID (9 digits) - * @returns {Promise} Application form submission fields + * @param {string} frfReviewItemId CSB Rebate ID with the form/version ID (9 digits) + * @returns {Promise} FRF submission fields */ -async function queryBapForPaymentRequestData(req, applicationReviewItemId) { +async function queryBapForPRFData(req, frfReviewItemId) { const logMessage = - `Querying the BAP for Application form submission associated with ` + - `Application Review Item ID: '${applicationReviewItemId}'.`; + `Querying the BAP for FRF submission associated with ` + + `FRF Review Item ID: '${frfReviewItemId}'.`; log({ level: "info", message: logMessage }); /** @type {jsforce.Connection} */ @@ -532,7 +529,7 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { // sobjecttype = '${BAP_FORMS_TABLE}' // LIMIT 1` - const applicationRecordTypeIdQuery = await bapConnection + const frfRecordTypeIdQuery = await bapConnection .sobject("recordtype") .find( { @@ -547,7 +544,7 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { .limit(1) .execute(async (err, records) => ((await err) ? err : records)); - const applicationRecordTypeId = applicationRecordTypeIdQuery["0"].Id; + const frfRecordTypeId = frfRecordTypeIdQuery["0"].Id; // `SELECT // Id, @@ -570,16 +567,16 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { // FROM // ${BAP_FORMS_TABLE} // WHERE - // recordtypeid = '${applicationRecordTypeId}' AND - // CSB_Review_Item_ID__c = '${applicationReviewItemId}' AND + // recordtypeid = '${frfRecordTypeId}' AND + // CSB_Review_Item_ID__c = '${frfReviewItemId}' AND // Latest_Version__c = TRUE` - const applicationRecordQuery = await bapConnection + const frfRecordQuery = await bapConnection .sobject(BAP_FORMS_TABLE) .find( { - recordtypeid: applicationRecordTypeId, - CSB_Review_Item_ID__c: applicationReviewItemId, + recordtypeid: frfRecordTypeId, + CSB_Review_Item_ID__c: frfReviewItemId, Latest_Version__c: true, }, { @@ -605,7 +602,7 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { ) .execute(async (err, records) => ((await err) ? err : records)); - const applicationRecordId = applicationRecordQuery["0"].Id; + const frfRecordId = frfRecordQuery["0"].Id; // `SELECT // Id @@ -644,7 +641,7 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { // ${BAP_BUS_TABLE} // WHERE // recordtypeid = '${busRecordTypeId}' AND - // Related_Order_Request__c = '${applicationRecordId}' AND + // Related_Order_Request__c = '${frfRecordId}' AND // CSB_Rebate_Item_Type__c = 'Old Bus'` const busRecordsQuery = await bapConnection @@ -652,7 +649,7 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { .find( { recordtypeid: busRecordTypeId, - Related_Order_Request__c: applicationRecordId, + Related_Order_Request__c: frfRecordId, CSB_Rebate_Item_Type__c: "Old Bus", }, { @@ -667,29 +664,23 @@ async function queryBapForPaymentRequestData(req, applicationReviewItemId) { ) .execute(async (err, records) => ((await err) ? err : records)); - return { applicationRecordQuery, busRecordsQuery }; + return { frfRecordQuery, busRecordsQuery }; } /** - * Uses cached JSforce connection to query the BAP for Application form - * submission data and Payment Request form submission data, for use in a brand - * new Close-out form submission. + * Uses cached JSforce connection to query the BAP for FRF submission data and + * PRF submission data, for use in a brand new CRF submission. * * @param {express.Request} req - * @param {string} applicationReviewItemId CSB Rebate ID with the form/version ID (9 digits) - * @param {string} paymentRequestReviewItemId CSB Rebate ID with the form/version ID (9 digits) - * @returns {Promise} Application form and Payment Request form submission fields + * @param {string} frfReviewItemId CSB Rebate ID with the form/version ID (9 digits) + * @param {string} prfReviewItemId CSB Rebate ID with the form/version ID (9 digits) + * @returns {Promise} FRF and PRF submission fields */ -async function queryBapForCloseOutData( - req, - applicationReviewItemId, - paymentRequestReviewItemId -) { +async function queryBapForCRFData(req, frfReviewItemId, prfReviewItemId) { const logMessage = - `Querying the BAP for Application form submission associated with ` + - `Application Review Item ID: '${applicationReviewItemId}' and ` + - `Payment Request form submission associated with ` + - `Payment Request Review Item ID: '${paymentRequestReviewItemId}'.`; + `Querying the BAP for FRF submission associated with ` + + `FRF Review Item ID: '${frfReviewItemId}' and PRF submission associated with ` + + `PRF Review Item ID: '${prfReviewItemId}'.`; log({ level: "info", message: logMessage }); /** @type {jsforce.Connection} */ @@ -704,7 +695,7 @@ async function queryBapForCloseOutData( // sobjecttype = '${BAP_FORMS_TABLE}' // LIMIT 1` - const applicationRecordTypeIdQuery = await bapConnection + const frfRecordTypeIdQuery = await bapConnection .sobject("recordtype") .find( { @@ -719,7 +710,7 @@ async function queryBapForCloseOutData( .limit(1) .execute(async (err, records) => ((await err) ? err : records)); - const applicationRecordTypeId = applicationRecordTypeIdQuery["0"].Id; + const frfRecordTypeId = frfRecordTypeIdQuery["0"].Id; // `SELECT // Fleet_Name__c, @@ -736,16 +727,16 @@ async function queryBapForCloseOutData( // FROM // ${BAP_FORMS_TABLE} // WHERE - // recordtypeid = '${applicationRecordTypeId}' AND - // CSB_Review_Item_ID__c = '${applicationReviewItemId}' AND + // recordtypeid = '${frfRecordTypeId}' AND + // CSB_Review_Item_ID__c = '${frfReviewItemId}' AND // Latest_Version__c = TRUE` - const applicationRecordQuery = await bapConnection + const frfRecordQuery = await bapConnection .sobject(BAP_FORMS_TABLE) .find( { - recordtypeid: applicationRecordTypeId, - CSB_Review_Item_ID__c: applicationReviewItemId, + recordtypeid: frfRecordTypeId, + CSB_Review_Item_ID__c: frfReviewItemId, Latest_Version__c: true, }, { @@ -774,7 +765,7 @@ async function queryBapForCloseOutData( // sobjecttype = '${BAP_FORMS_TABLE}' // LIMIT 1` - const paymentRequestRecordTypeIdQuery = await bapConnection + const prfRecordTypeIdQuery = await bapConnection .sobject("recordtype") .find( { @@ -789,7 +780,7 @@ async function queryBapForCloseOutData( .limit(1) .execute(async (err, records) => ((await err) ? err : records)); - const paymentRequestRecordTypeId = paymentRequestRecordTypeIdQuery["0"].Id; + const prfRecordTypeId = prfRecordTypeIdQuery["0"].Id; // `SELECT // Id, @@ -822,16 +813,16 @@ async function queryBapForCloseOutData( // FROM // ${BAP_FORMS_TABLE} // WHERE - // recordtypeid = '${paymentRequestRecordTypeId}' AND - // CSB_Review_Item_ID__c = '${paymentRequestReviewItemId}' AND + // recordtypeid = '${prfRecordTypeId}' AND + // CSB_Review_Item_ID__c = '${prfReviewItemId}' AND // Latest_Version__c = TRUE` - const paymentRequestRecordQuery = await bapConnection + const prfRecordQuery = await bapConnection .sobject(BAP_FORMS_TABLE) .find( { - recordtypeid: paymentRequestRecordTypeId, - CSB_Review_Item_ID__c: paymentRequestReviewItemId, + recordtypeid: prfRecordTypeId, + CSB_Review_Item_ID__c: prfReviewItemId, Latest_Version__c: true, }, { @@ -867,7 +858,7 @@ async function queryBapForCloseOutData( ) .execute(async (err, records) => ((await err) ? err : records)); - const paymentRequestRecordId = paymentRequestRecordQuery["0"].Id; + const prfRecordId = prfRecordQuery["0"].Id; // `SELECT // Id @@ -916,7 +907,7 @@ async function queryBapForCloseOutData( // ${BAP_BUS_TABLE} // WHERE // recordtypeid = '${busRecordTypeId}' AND - // Related_Order_Request__c = '${paymentRequestRecordId}' AND + // Related_Order_Request__c = '${prfRecordId}' AND // CSB_Rebate_Item_Type__c = 'New Bus'` const busRecordsQuery = await bapConnection @@ -924,7 +915,7 @@ async function queryBapForCloseOutData( .find( { recordtypeid: busRecordTypeId, - Related_Order_Request__c: paymentRequestRecordId, + Related_Order_Request__c: prfRecordId, CSB_Rebate_Item_Type__c: "New Bus", }, { @@ -949,7 +940,7 @@ async function queryBapForCloseOutData( ) .execute(async (err, records) => ((await err) ? err : records)); - return { applicationRecordQuery, paymentRequestRecordQuery, busRecordsQuery }; + return { frfRecordQuery, prfRecordQuery, busRecordsQuery }; } /** @@ -1025,7 +1016,7 @@ function getBapComboKeys(req, email) { * Fetches data associated with a provided form submission. * * @param {express.Request} req - * @param {'application' | 'payment-request' | 'close-out'} formType + * @param {'frf' | 'prf' | 'crf'} formType * @param {string | null} rebateId * @param {string | null} mongoId * @returns {ReturnType} @@ -1052,38 +1043,76 @@ function getBapFormSubmissionsStatuses(req, comboKeys) { } /** - * Fetches Application form submission data associated with an Application - * Review Item ID. + * Fetches FRF submission data associated with a FRF Review Item ID. * * @param {express.Request} req - * @param {string} applicationReviewItemId - * @returns {ReturnType} + * @param {string} frfReviewItemId + * @returns {ReturnType} */ -function getBapDataForPaymentRequest(req, applicationReviewItemId) { +function getBapDataForPRF(req, frfReviewItemId) { return verifyBapConnection(req, { - name: queryBapForPaymentRequestData, - args: [req, applicationReviewItemId], + name: queryBapForPRFData, + args: [req, frfReviewItemId], }); } /** - * Fetches Application form submission data and Payment Request form submission - * data associated with an Application Review Item ID and a Payment Request - * Review Item ID. + * Fetches FRF submission data and PRF submission data associated with a FRF + * Review Item ID and a PRF Review Item ID. * * @param {express.Request} req - * @param {string} applicationReviewItemId - * @param {string} paymentRequestReviewItemId - * @returns {ReturnType} + * @param {string} frfReviewItemId + * @param {string} prfReviewItemId + * @returns {ReturnType} */ -function getBapDataForCloseOut( - req, - applicationReviewItemId, - paymentRequestReviewItemId -) { +function getBapDataForCRF(req, frfReviewItemId, prfReviewItemId) { return verifyBapConnection(req, { - name: queryBapForCloseOutData, - args: [req, applicationReviewItemId, paymentRequestReviewItemId], + name: queryBapForCRFData, + args: [req, frfReviewItemId, prfReviewItemId], + }); +} + +/** + * Returns a resolved or rejected promise, depending on if the given form's + * submission period is open (as set via environment variables), and if the form + * submission has the status of "Edits Requested" or not (as stored in and + * returned from the BAP). + * + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {'frf' | 'prf' | 'crf'} param.formType + * @param {string} param.mongoId + * @param {string} param.comboKey + * @param {express.Request} param.req + */ +function checkFormSubmissionPeriodAndBapStatus({ + rebateYear, + formType, + mongoId, + comboKey, + req, +}) { + /** Form submission period is open, so continue. */ + if (submissionPeriodOpen[rebateYear][formType]) { + return Promise.resolve(); + } + + /** Form submission period is closed, so only continue if edits are requested. */ + return getBapFormSubmissionsStatuses(req, [comboKey]).then((submissions) => { + const submission = submissions.find((s) => s.CSB_Form_ID__c === mongoId); + + const statusField = + formType === "frf" + ? "CSB_Funding_Request_Status__c" + : formType === "prf" + ? "CSB_Payment_Request_Status__c" + : formType === "crf" + ? "CSB_Closeout_Request_Status__c" + : null; + + return submission?.Parent_CSB_Rebate__r?.[statusField] === "Edits Requested" + ? Promise.resolve() + : Promise.reject(); }); } @@ -1092,6 +1121,7 @@ module.exports = { getBapComboKeys, getBapFormSubmissionData, getBapFormSubmissionsStatuses, - getBapDataForPaymentRequest, - getBapDataForCloseOut, + getBapDataForPRF, + getBapDataForCRF, + checkFormSubmissionPeriodAndBapStatus, }; diff --git a/app/server/app/utilities/formio.js b/app/server/app/utilities/formio.js new file mode 100644 index 00000000..4f509c88 --- /dev/null +++ b/app/server/app/utilities/formio.js @@ -0,0 +1,362 @@ +const express = require("express"); +// --- +const { + axiosFormio, + formUrl, + submissionPeriodOpen, + formioCSBMetadata, +} = require("../config/formio"); +const { checkFormSubmissionPeriodAndBapStatus } = require("./bap"); +const log = require("./logger"); + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + */ +function getComboKeyFieldName({ rebateYear }) { + return rebateYear === "2022" + ? "bap_hidden_entity_combo_key" + : rebateYear === "2023" + ? "_bap_entity_combo_key" + : ""; +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function uploadS3FileMetadata({ rebateYear, req, res }) { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { formType, mongoId, comboKey } = req.params; + + const formioFormUrl = formUrl[rebateYear][formType]; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} ${formType.toUpperCase()}.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + checkFormSubmissionPeriodAndBapStatus({ + rebateYear, + formType, + mongoId, + comboKey, + req, + }) + .then(() => { + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to upload a file ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + axiosFormio(req) + .post(`${formioFormUrl}/storage/s3`, body) + .then((axiosRes) => axiosRes.data) + .then((fileMetadata) => res.json(fileMetadata)) + .catch((error) => { + // NOTE: logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error uploading file to S3.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + const formName = + formType === "frf" + ? "CSB Application" + : formType === "prf" + ? "CSB Payment Request" + : formType === "cof" + ? "CSB Close Out" + : "CSB"; + + const logMessage = + `User with email '${mail}' attempted to upload a file when the ` + + `${rebateYear} ${formName} form enrollment period was closed.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 400; + const errorMessage = `${rebateYear} ${formName} form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function downloadS3FileMetadata({ rebateYear, req, res }) { + const { bapComboKeys, query } = req; + const { mail } = req.user; + const { formType, comboKey } = req.params; + + const formioFormUrl = formUrl[rebateYear][formType]; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} ${formType.toUpperCase()}.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to download a file ` + + `without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + axiosFormio(req) + .get(`${formioFormUrl}/storage/s3`, { params: query }) + .then((axiosRes) => axiosRes.data) + .then((fileMetadata) => res.json(fileMetadata)) + .catch((error) => { + // NOTE: logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error downloading file from S3.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchFRFSubmissions({ rebateYear, req, res }) { + const { bapComboKeys } = req; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKeySearchParam = `&data.${comboKeyFieldName}=`; + + const formioFormUrl = formUrl[rebateYear].frf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} FRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const submissionsUrl = + `${formioFormUrl}/submission` + + `?sort=-modified` + + `&limit=1000000` + + comboKeySearchParam + + `${bapComboKeys.join(comboKeySearchParam)}`; + + axiosFormio(req) + .get(submissionsUrl) + .then((axiosRes) => axiosRes.data) + .then((submissions) => res.json(submissions)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio ${rebateYear} Application form submissions.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function createFRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys, body } = req; + const { mail } = req.user; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKey = body.data?.[comboKeyFieldName]; + + const formioFormUrl = formUrl[rebateYear].frf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} FRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!submissionPeriodOpen[rebateYear].frf) { + const errorStatus = 400; + const errorMessage = `${rebateYear} CSB Application form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to post a new ${rebateYear} ` + + `FRF submission without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** Add custom metadata to track formio submissions from wrapper. */ + body.metadata = { ...formioCSBMetadata }; + + axiosFormio(req) + .post(`${formioFormUrl}/submission`, body) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error posting Formio ${rebateYear} Application form submission.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchFRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys } = req; + const { mail } = req.user; + const { mongoId } = req.params; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + + const formioFormUrl = formUrl[rebateYear].frf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} FRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + Promise.all([ + axiosFormio(req).get(`${formioFormUrl}/submission/${mongoId}`), + axiosFormio(req).get(formioFormUrl), + ]) + .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) + .then(([submission, schema]) => { + const comboKey = submission.data?.[comboKeyFieldName]; + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to access ${rebateYear} ` + + `FRF submission '${mongoId}' that they do not have access to.`; + log({ level: "warn", message: logMessage, req }); + + return res.json({ + userAccess: false, + formSchema: null, + submission: null, + }); + } + + return res.json({ + userAccess: true, + formSchema: { url: formioFormUrl, json: schema }, + submission, + }); + }) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error getting Formio ${rebateYear} Application form submission '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {'2022' | '2023'} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function updateFRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys } = req; + const { mail } = req.user; + const { mongoId } = req.params; + const submission = req.body; + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKey = submission.data?.[comboKeyFieldName]; + + const formioFormUrl = formUrl[rebateYear].frf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} FRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + checkFormSubmissionPeriodAndBapStatus({ + rebateYear, + formType: "frf", + mongoId, + comboKey, + req, + }) + .then(() => { + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to update ${rebateYear} FRF ` + + `submission '${mongoId}' without a matching BAP combo key.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 401; + const errorMessage = `Unauthorized.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + /** Add custom metadata to track formio submissions from wrapper. */ + submission.metadata = { + ...submission.metadata, + ...formioCSBMetadata, + }; + + axiosFormio(req) + .put(`${formioFormUrl}/submission/${mongoId}`, submission) + .then((axiosRes) => axiosRes.data) + .then((submission) => res.json(submission)) + .catch((error) => { + // NOTE: error is logged in axiosFormio response interceptor + const errorStatus = error.response?.status || 500; + const errorMessage = `Error updating Formio ${rebateYear} Application form submission '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + const logMessage = + `User with email '${mail}' attempted to update ${rebateYear} FRF ` + + `submission '${mongoId}' when the CSB FRF enrollment period was closed.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 400; + const errorMessage = `${rebateYear} CSB Application form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +module.exports = { + uploadS3FileMetadata, + downloadS3FileMetadata, + fetchFRFSubmissions, + createFRFSubmission, + fetchFRFSubmission, + updateFRFSubmission, +}; diff --git a/docs/csb-openapi.json b/docs/csb-openapi.json index 529e478c..ed4eba42 100644 --- a/docs/csb-openapi.json +++ b/docs/csb-openapi.json @@ -166,9 +166,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/help/formio-submission/{formType}/{id}": { + "/api/help/formio/submission/{formType}/{id}": { "get": { - "summary": "/help/formio-submission/{formType}/{id}", + "summary": "/api/help/formio/submission/{formType}/{id}", "parameters": [ { "name": "formType", @@ -308,9 +308,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/csb-data": { + "/api/config": { "get": { - "summary": "/api/csb-data", + "summary": "/api/config", "responses": { "200": { "description": "OK", @@ -349,9 +349,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/bap-sam-data": { + "/api/bap/sam": { "get": { - "summary": "/api/bap-sam-data", + "summary": "/api/bap/sam", "responses": { "200": { "description": "OK", @@ -380,9 +380,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/bap-form-submissions": { + "/api/bap/submissions": { "get": { - "summary": "/api/bap-form-submissions", + "summary": "/api/bap/submissions", "responses": { "200": { "description": "OK", @@ -402,9 +402,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/s3/{formType}/{mongoId}/{comboKey}/storage/s3": { + "/api/formio/2022/s3/{formType}/{mongoId}/{comboKey}/storage/s3": { "get": { - "summary": "/api/s3/{formType}/{mongoId}/{comboKey}/storage/s3", + "summary": "/api/formio/2022/s3/{formType}/{mongoId}/{comboKey}/storage/s3", "responses": { "200": { "description": "OK" @@ -440,7 +440,7 @@ ] }, "post": { - "summary": "/api/s3/{formType}/{mongoId}/{comboKey}/storage/s3", + "summary": "/api/formio/2022/s3/{formType}/{mongoId}/{comboKey}/storage/s3", "responses": { "200": { "description": "OK" @@ -476,9 +476,9 @@ ] } }, - "/api/formio-application-submissions": { + "/api/formio/2022/frf-submissions": { "get": { - "summary": "/api/formio-application-submissions", + "summary": "/api/formio/2022/frf-submissions", "responses": { "200": { "description": "OK", @@ -498,9 +498,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-application-submission": { + "/api/formio/2022/frf-submission": { "post": { - "summary": "/api/formio-application-submission", + "summary": "/api/formio/2022/frf-submission", "responses": { "200": { "description": "OK", @@ -520,9 +520,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-application-submission/{mongoId}": { + "/api/formio/2022/frf-submission/{mongoId}": { "get": { - "summary": "/api/formio-application-submission/{mongoId}", + "summary": "/api/formio/2022/frf-submission/{mongoId}", "parameters": [ { "name": "mongoId", @@ -570,7 +570,7 @@ "tags": [] }, "post": { - "summary": "/api/formio-application-submission/{mongoId}", + "summary": "/api/formio/2022/frf-submission/{mongoId}", "parameters": [ { "name": "mongoId", @@ -597,9 +597,9 @@ "tags": [] } }, - "/api/formio-payment-request-submissions": { + "/api/formio/2022/prf-submissions": { "get": { - "summary": "/api/formio-payment-request-submissions", + "summary": "/api/formio/2022/prf-submissions", "responses": { "200": { "description": "OK", @@ -619,9 +619,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-payment-request-submission": { + "/api/formio/2022/prf-submission": { "post": { - "summary": "/api/formio-payment-request-submission", + "summary": "/api/formio/2022/prf-submission", "responses": { "200": { "description": "OK", @@ -641,9 +641,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-payment-request-submission/{rebateId}": { + "/api/formio/2022/prf-submission/{rebateId}": { "get": { - "summary": "/api/formio-payment-request-submission/{rebateId}", + "summary": "/api/formio/2022/prf-submission/{rebateId}", "parameters": [ { "name": "rebateId", @@ -691,7 +691,7 @@ "tags": [] }, "post": { - "summary": "/api/formio-payment-request-submission/{rebateId}", + "summary": "/api/formio/2022/prf-submission/{rebateId}", "parameters": [ { "name": "rebateId", @@ -718,9 +718,9 @@ "tags": [] } }, - "/api/delete-formio-payment-request-submission": { + "/api/formio/2022/delete-prf-submission": { "post": { - "summary": "/api/delete-formio-payment-request-submission", + "summary": "/api/formio/2022/delete-prf-submission", "responses": { "200": { "description": "OK", @@ -737,9 +737,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-close-out-submissions": { + "/api/formio/2022/crf-submissions": { "get": { - "summary": "/api/formio-close-out-submissions", + "summary": "/api/formio/2022/crf-submissions", "responses": { "200": { "description": "OK", @@ -759,9 +759,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-close-out-submission": { + "/api/formio/2022/crf-submission": { "post": { - "summary": "/api/formio-close-out-submission", + "summary": "/api/formio/2022/crf-submission", "responses": { "200": { "description": "OK", @@ -781,9 +781,9 @@ "parameters": [{ "$ref": "#/components/parameters/scan" }] } }, - "/api/formio-close-out-submission/{rebateId}": { + "/api/formio/2022/crf-submission/{rebateId}": { "get": { - "summary": "/api/formio-close-out-submission/{rebateId}", + "summary": "/api/formio/2022/crf-submission/{rebateId}", "parameters": [ { "name": "rebateId", @@ -831,7 +831,7 @@ "tags": [] }, "post": { - "summary": "/api/formio-close-out-submission/{rebateId}", + "summary": "/api/formio/2022/crf-submission/{rebateId}", "parameters": [ { "name": "rebateId",
+ If you haven’t saved the current form, any changes you’ve made will be + lost. +
- {email} -
- If you haven’t saved the current form, any changes - you’ve made will be lost. -
- Error creating Payment Request{" "} - {application.bap?.rebateId}. -
- Please try again. -
- Error creating Close Out{" "} - {paymentRequest.bap?.rebateId}. -
+ {onSubmitSubmission.state === "submitted" ? ( + <> + Application {mongoId} submitted successfully. + > + ) : ( + <>Draft saved successfully.> + )} +
+ {onSubmitSubmission.state === "submitted" ? ( + <>Error submitting Application form.> + ) : ( + <>Error saving draft.> + )} +
+ Draft saved successfully. +
+ Error saving draft. +
+ Error creating Payment Request{" "} + {frf.bap?.rebateId}. +
+ Please try again. +
+ Error creating Close Out {prf.bap?.rebateId}. +