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 }) { -
- - -
- {rebate?.application.bap?.rebateId && ( + {rebate?.frf.bap?.rebateId && (
  • - 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() { >
    -
    +
    + + + ); + } + + // 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 ( + + + + + + ); + } + + // 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") && ( + + )} + +
    + +
    + + {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",