diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index ab584b7e..8cf703eb 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -47,6 +47,9 @@ jobs: CSB_2023_FRF_OPEN: true CSB_2023_PRF_OPEN: true CSB_2023_CRF_OPEN: true + CSB_2024_FRF_OPEN: true + CSB_2024_PRF_OPEN: true + CSB_2024_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 }} @@ -54,6 +57,10 @@ jobs: FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} FORMIO_2023_CHANGE_PATH: ${{ secrets.FORMIO_2023_CHANGE_PATH }} + FORMIO_2024_FRF_PATH: ${{ secrets.FORMIO_2024_FRF_PATH }} + FORMIO_2024_PRF_PATH: ${{ secrets.FORMIO_2024_PRF_PATH }} + FORMIO_2024_CRF_PATH: ${{ secrets.FORMIO_2024_CRF_PATH }} + FORMIO_2024_CHANGE_PATH: ${{ secrets.FORMIO_2024_CHANGE_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} @@ -133,6 +140,9 @@ jobs: 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 "CSB_2024_FRF_OPEN" "$CSB_2024_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2024_PRF_OPEN" "$CSB_2024_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2024_CRF_OPEN" "$CSB_2024_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 @@ -140,6 +150,10 @@ jobs: 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_2023_CHANGE_PATH" "$FORMIO_2023_CHANGE_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_FRF_PATH" "$FORMIO_2024_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_PRF_PATH" "$FORMIO_2024_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_CRF_PATH" "$FORMIO_2024_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_CHANGE_PATH" "$FORMIO_2024_CHANGE_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_API_KEY" "$FORMIO_API_KEY" > /dev/null diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index b2b6d5e7..873b3d80 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -47,6 +47,9 @@ jobs: CSB_2023_FRF_OPEN: true CSB_2023_PRF_OPEN: true CSB_2023_CRF_OPEN: true + CSB_2024_FRF_OPEN: true + CSB_2024_PRF_OPEN: true + CSB_2024_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 }} @@ -54,6 +57,10 @@ jobs: FORMIO_2023_PRF_PATH: ${{ secrets.FORMIO_2023_PRF_PATH }} FORMIO_2023_CRF_PATH: ${{ secrets.FORMIO_2023_CRF_PATH }} FORMIO_2023_CHANGE_PATH: ${{ secrets.FORMIO_2023_CHANGE_PATH }} + FORMIO_2024_FRF_PATH: ${{ secrets.FORMIO_2024_FRF_PATH }} + FORMIO_2024_PRF_PATH: ${{ secrets.FORMIO_2024_PRF_PATH }} + FORMIO_2024_CRF_PATH: ${{ secrets.FORMIO_2024_CRF_PATH }} + FORMIO_2024_CHANGE_PATH: ${{ secrets.FORMIO_2024_CHANGE_PATH }} FORMIO_BASE_URL: ${{ secrets.FORMIO_BASE_URL }} FORMIO_PROJECT_NAME: ${{ secrets.FORMIO_PROJECT_NAME }} FORMIO_API_KEY: ${{ secrets.FORMIO_API_KEY }} @@ -133,6 +140,9 @@ jobs: 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 "CSB_2024_FRF_OPEN" "$CSB_2024_FRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2024_PRF_OPEN" "$CSB_2024_PRF_OPEN" > /dev/null + cf set-env $APP_NAME "CSB_2024_CRF_OPEN" "$CSB_2024_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 @@ -140,6 +150,10 @@ jobs: 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_2023_CHANGE_PATH" "$FORMIO_2023_CHANGE_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_FRF_PATH" "$FORMIO_2024_FRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_PRF_PATH" "$FORMIO_2024_PRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_CRF_PATH" "$FORMIO_2024_CRF_PATH" > /dev/null + cf set-env $APP_NAME "FORMIO_2024_CHANGE_PATH" "$FORMIO_2024_CHANGE_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_API_KEY" "$FORMIO_API_KEY" > /dev/null diff --git a/app/client/src/components/app.tsx b/app/client/src/components/app.tsx index 08170074..b92141f4 100644 --- a/app/client/src/components/app.tsx +++ b/app/client/src/components/app.tsx @@ -37,10 +37,14 @@ import { FRFNew } from "@/routes/frfNew"; import { FRF2022 } from "@/routes/frf2022"; import { PRF2022 } from "@/routes/prf2022"; import { CRF2022 } from "@/routes/crf2022"; +import { Change2023 } from "@/routes/change2023"; import { FRF2023 } from "@/routes/frf2023"; import { PRF2023 } from "@/routes/prf2023"; // import { CRF2023 } from "@/routes/crf2023"; -import { Change2023 } from "@/routes/change2023"; +import { Change2024 } from "@/routes/change2024"; +import { FRF2024 } from "@/routes/frf2024"; +// import { PRF2024 } from "@/routes/prf2024"; +// import { CRF2024 } from "@/routes/crf2024"; import { useDialogState, useDialogActions } from "@/contexts/dialog"; /** Custom hook to display a site-wide alert banner */ @@ -252,11 +256,15 @@ export function App() { } /> } /> + } /> } /> } /> {/* } /> */} - } /> + } /> + } /> + {/* } /> */} + {/* } /> */} } /> diff --git a/app/client/src/components/change2023New.tsx b/app/client/src/components/change2023New.tsx index 09af89d1..44370c45 100644 --- a/app/client/src/components/change2023New.tsx +++ b/app/client/src/components/change2023New.tsx @@ -1,15 +1,14 @@ import { Fragment, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { Dialog, Transition } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { Form } from "@formio/react"; import clsx from "clsx"; import icons from "uswds/img/sprite.svg"; // --- +import { type FormType, type FormioChange2023Submission } from "@/types"; import { serverUrl, messages } from "@/config"; import { - type FormType, - type FormioChange2023Submission, getData, postData, useContentData, @@ -31,7 +30,7 @@ type ChangeRequestData = { name: string; }; -type ServerResponse = { url: string; json: object }; +type Response = { url: string; json: object }; /** Custom hook to fetch Formio schema */ function useFormioSchemaQuery() { @@ -39,13 +38,26 @@ function useFormioSchemaQuery() { const query = useQuery({ queryKey: ["formio/2023/change"], - queryFn: () => getData(url), + queryFn: () => getData(url), refetchOnWindowFocus: false, }); return { query }; } +/** Custom hook to update Formio submission submission data */ +function useFormioSubmissionMutation() { + const url = `${serverUrl}/api/formio/2023/change/`; + + const mutation = useMutation({ + mutationFn: (submission: FormioChange2023Submission) => { + return postData(url, submission); + }, + }); + + return { mutation }; +} + export function ChangeRequest2023Button(props: { data: ChangeRequestData }) { const { data } = props; @@ -96,11 +108,10 @@ function ChangeRequest2023Dialog(props: { const { dialogShown, closeDialog, data } = props; /* - * NOTE: For some reason select inputs from the Formio form won't receive - * click events if the Dialog.Panel component is used (strangely, they still - * receive keyboard events), so a div is used instead. The downside is we lose - * the triggering of the Dialog component's `onClose` event when a user clicks - * outside the panel. + * NOTE: Formio form Combobox inputs won't receive click events if the + * Dialog.Panel component is used (they still receive keyboard events), so a + * div is used instead. The downside is we lose the triggering of the Dialog + * component's `onClose` event when a user clicks outside the panel. */ return ( @@ -142,7 +153,7 @@ function ChangeRequest2023Dialog(props: { leaveFrom={clsx("tw-translate-y-0 tw-opacity-100")} leaveTo={clsx("tw-translate-y-0 tw-opacity-0")} > - {/* */}
({}); + if (query.isInitialLoading) { return ; } @@ -227,11 +256,31 @@ function ChangeRequest2023Form(props: { <> {content && } + {}}> +
+
+
+ + + +
+
+
+
{ + onSubmit={(onSubmitSubmission: FormioChange2023Submission) => { // account for when form is being submitted to prevent double submits if (formIsBeingSubmitted.current) return; formIsBeingSubmitted.current = true; + const data = { ...onSubmitSubmission.data }; + dismissNotification({ id: 0 }); + dataIsPosting.current = true; + pendingSubmissionData.current = data; + + mutation.mutate(onSubmitSubmission, { + onSuccess: (res, _payload, _context) => { + pendingSubmissionData.current = {}; - postData( - `${serverUrl}/api/formio/2023/change/`, - onSubmitSubmission, - ) - .then((res) => { displaySuccessNotification({ id: Date.now(), body: ( @@ -277,10 +326,13 @@ function ChangeRequest2023Form(props: { closeDialog(); changeRequestsQuery.refetch(); - }) - .catch((_err) => { + }, + onError: (_error, _payload, _context) => { + /** error notification id */ + const id = Date.now(); + displayErrorNotification({ - id: Date.now(), + id, body: ( <>

), }); - }) - .finally(() => { + + setTimeout(() => dismissNotification({ id }), 5000); + }, + onSettled: (_data, _error, _payload, _context) => { + dataIsPosting.current = false; formIsBeingSubmitted.current = false; - }); + }, + }); }} />

diff --git a/app/client/src/components/change2024New.tsx b/app/client/src/components/change2024New.tsx new file mode 100644 index 00000000..d6001b9e --- /dev/null +++ b/app/client/src/components/change2024New.tsx @@ -0,0 +1,370 @@ +import { Fragment, useRef, useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Dialog, Transition } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { Form } from "@formio/react"; +import clsx from "clsx"; +import icons from "uswds/img/sprite.svg"; +// --- +import { type FormType, type FormioChange2024Submission } from "@/types"; +import { serverUrl, messages } from "@/config"; +import { + getData, + postData, + useContentData, + useChangeRequestsQuery, +} from "@/utilities"; +import { Loading } from "@/components/loading"; +import { Message } from "@/components/message"; +import { MarkdownContent } from "@/components/markdownContent"; +import { useNotificationsActions } from "@/contexts/notifications"; + +type ChangeRequestData = { + formType: FormType; + comboKey: string; + rebateId: string | null; + mongoId: string; + state: "draft" | "submitted"; + email: string; + title: string; + name: string; +}; + +type Response = { url: string; json: object }; + +/** Custom hook to fetch Formio schema */ +function useFormioSchemaQuery() { + const url = `${serverUrl}/api/formio/2024/change`; + + const query = useQuery({ + queryKey: ["formio/2024/change"], + queryFn: () => getData(url), + refetchOnWindowFocus: false, + }); + + return { query }; +} + +/** Custom hook to update Formio submission submission data */ +function useFormioSubmissionMutation() { + const url = `${serverUrl}/api/formio/2024/change/`; + + const mutation = useMutation({ + mutationFn: (submission: FormioChange2024Submission) => { + return postData(url, submission); + }, + }); + + return { mutation }; +} + +export function ChangeRequest2024Button(props: { data: ChangeRequestData }) { + const { data } = props; + + const [dialogShown, setDialogShown] = useState(false); + + function closeDialog() { + setDialogShown(false); + } + + return ( + <> + + + + + ); +} + +function ChangeRequest2024Dialog(props: { + dialogShown: boolean; + closeDialog: () => void; + data: ChangeRequestData; +}) { + const { dialogShown, closeDialog, data } = props; + + /* + * NOTE: Formio form Combobox inputs won't receive click events if the + * Dialog.Panel component is used (they still receive keyboard events), so a + * div is used instead. The downside is we lose the triggering of the Dialog + * component's `onClose` event when a user clicks outside the panel. + */ + + return ( + + closeDialog()} + > + +
+ + +
+
+ + {/* */} +
+
+
+ +
+
+ +
+ +
+
+ {/*
*/} +
+
+
+
+
+ ); +} + +function ChangeRequest2024Form(props: { + data: ChangeRequestData; + closeDialog: () => void; +}) { + const { data, closeDialog } = props; + const { formType, comboKey, rebateId, mongoId, state, email, title, name } = + data; + + const content = useContentData(); + const { + displaySuccessNotification, + displayErrorNotification, + dismissNotification, + } = useNotificationsActions(); + + const changeRequestsQuery = useChangeRequestsQuery("2024"); + + const { query } = useFormioSchemaQuery(); + const { mutation } = useFormioSubmissionMutation(); + + const formSchema = 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 Submit button. + * As soon as a post request to submit the data succeeds, this pending + * submission data is reset to an empty object. This pending data is passed + * into the Form component's `submission` prop. + */ + const pendingSubmissionData = useRef<{ [field: string]: unknown }>({}); + + if (query.isInitialLoading) { + return ; + } + + if (query.isError || !formSchema) { + return ; + } + + return ( + <> + {content && } + + {}}> +
+
+
+ + + +
+
+
+ +
+ { + // account for when form is being submitted to prevent double submits + if (formIsBeingSubmitted.current) return; + formIsBeingSubmitted.current = true; + + const data = { ...onSubmitSubmission.data }; + + dismissNotification({ id: 0 }); + dataIsPosting.current = true; + pendingSubmissionData.current = data; + + mutation.mutate(onSubmitSubmission, { + onSuccess: (res, _payload, _context) => { + pendingSubmissionData.current = {}; + + displaySuccessNotification({ + id: Date.now(), + body: ( +

+ Change Request {res._id} submitted successfully. +

+ ), + }); + + closeDialog(); + changeRequestsQuery.refetch(); + }, + onError: (_error, _payload, _context) => { + /** error notification id */ + const id = Date.now(); + + displayErrorNotification({ + id, + body: ( + <> +

+ Error creating Change Request for{" "} + + {formType.toUpperCase()} {rebateId} + + . +

+

+ Please try again. +

+ + ), + }); + + setTimeout(() => dismissNotification({ id }), 5000); + }, + onSettled: (_data, _error, _payload, _context) => { + dataIsPosting.current = false; + formIsBeingSubmitted.current = false; + }, + }); + }} + /> +
+ + ); +} diff --git a/app/client/src/components/confirmationDialog.tsx b/app/client/src/components/confirmationDialog.tsx index 0e48390f..344ebb9f 100644 --- a/app/client/src/components/confirmationDialog.tsx +++ b/app/client/src/components/confirmationDialog.tsx @@ -73,10 +73,10 @@ export function ConfirmationDialog() { - {dismissable && dismissText && ( + {dismissable && (
(), // TODO }, + 2024: { + frf: new Map() + .set("Needs Clarification", "Needs Clarification") + .set("Withdrawn", "Withdrawn") + .set("Coordinator Denied", "Not Selected") + .set("Accepted", "Selected"), + prf: new Map(), // TODO + crf: new Map(), // TODO + }, }; /** @@ -143,9 +152,14 @@ export const formioNameField = { crf: "signatureName", }, 2023: { - frf: "_bap_applicant_name", - prf: "", - crf: "", + frf: "_user_name", + prf: "_user_name", + crf: "", // TODO + }, + 2024: { + frf: "", // TODO + prf: "", // TODO + crf: "", // TODO }, }; @@ -160,7 +174,33 @@ export const formioEmailField = { }, 2023: { frf: "_user_email", - prf: "", - crf: "", + prf: "_user_email", + crf: "", // TODO + }, + 2024: { + frf: "", // TODO + prf: "", // TODO + crf: "", // TODO + }, +}; + +/** + * Formio BAP rebate ID field by year and form type. + */ +export const formioBapRebateIdField = { + 2022: { + frf: "", // NOTE: no BAP rebate ID in the FRF + prf: "hidden_bap_rebate_id", + crf: "hidden_bap_rebate_id", + }, + 2023: { + frf: "", // NOTE: no BAP rebate ID in the FRF + prf: "_bap_rebate_id", + crf: "_bap_rebate_id", + }, + 2024: { + frf: "", // NOTE: no BAP rebate ID in the FRF + prf: "", // TODO + crf: "", // TODO }, }; diff --git a/app/client/src/contexts/rebateYear.tsx b/app/client/src/contexts/rebateYear.tsx index 9823eb38..495cad28 100644 --- a/app/client/src/contexts/rebateYear.tsx +++ b/app/client/src/contexts/rebateYear.tsx @@ -7,13 +7,13 @@ import { useContext, useReducer, } from "react"; +// --- +import { type RebateYear } from "@/types"; type Props = { children: ReactNode; }; -export type RebateYear = "2022" | "2023"; - type State = { rebateYear: RebateYear; }; diff --git a/app/client/src/routes/change2023.tsx b/app/client/src/routes/change2023.tsx index 6fa59785..0e46f765 100644 --- a/app/client/src/routes/change2023.tsx +++ b/app/client/src/routes/change2023.tsx @@ -4,27 +4,17 @@ import { useQueryClient, useQuery } from "@tanstack/react-query"; import { Form } from "@formio/react"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioChange2023Submission, - getData, - useContentData, -} from "@/utilities"; +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, useContentData } from "@/utilities"; import { Loading } from "@/components/loading"; import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; -type ServerResponse = - | { - userAccess: false; - formSchema: null; - submission: null; - } - | { - userAccess: true; - formSchema: { url: string; json: object }; - submission: FormioChange2023Submission; - }; +type Response = FormioSchemaAndSubmission; /** Custom hook to fetch Formio submission data */ function useFormioSubmissionQuery(mongoId: string | undefined) { @@ -38,7 +28,7 @@ function useFormioSubmissionQuery(mongoId: string | undefined) { const query = useQuery({ queryKey: ["formio/2023/change", { id: mongoId }], - queryFn: () => getData(url), + queryFn: () => getData(url), refetchOnWindowFocus: false, }); @@ -88,7 +78,8 @@ export function Change2023() { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ - data: { ...submission.data }, + state: submission.state, + data: submission.data, }} options={{ readOnly: true, diff --git a/app/client/src/routes/change2024.tsx b/app/client/src/routes/change2024.tsx new file mode 100644 index 00000000..a340655d --- /dev/null +++ b/app/client/src/routes/change2024.tsx @@ -0,0 +1,92 @@ +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { Form } from "@formio/react"; +import icons from "uswds/img/sprite.svg"; +// --- +import { + type FormioSchemaAndSubmission, + type FormioChange2024Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, useContentData } from "@/utilities"; +import { Loading } from "@/components/loading"; +import { Message } from "@/components/message"; +import { MarkdownContent } from "@/components/markdownContent"; + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch Formio submission data */ +function useFormioSubmissionQuery(mongoId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.resetQueries({ queryKey: ["formio/2024/change"] }); + }, [queryClient]); + + const url = `${serverUrl}/api/formio/2024/change/${mongoId}`; + + const query = useQuery({ + queryKey: ["formio/2024/change", { id: mongoId }], + queryFn: () => getData(url), + refetchOnWindowFocus: false, + }); + + return { query }; +} + +export function Change2024() { + const { id: mongoId } = useParams<"id">(); // MongoDB ObjectId string + + const content = useContentData(); + + const { query } = useFormioSubmissionQuery(mongoId); + const { userAccess, formSchema, submission } = query.data ?? {}; + + if (query.isInitialLoading) { + return ; + } + + if (query.isError || !userAccess || !formSchema || !submission) { + return ; + } + + return ( +
+ {content && ( + + )} + +
    +
  • +
    + +
    +
    + Change Request ID: {submission._id} +
    +
  • +
+ +
+ +
+
+ ); +} diff --git a/app/client/src/routes/crf2022.tsx b/app/client/src/routes/crf2022.tsx index 85ea267f..9c264a3f 100644 --- a/app/client/src/routes/crf2022.tsx +++ b/app/client/src/routes/crf2022.tsx @@ -8,9 +8,12 @@ import clsx from "clsx"; import { cloneDeep, isEqual } from "lodash"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioCRF2022Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, postData, useContentData, @@ -25,21 +28,10 @@ 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: FormioCRF2022Submission; - }; - -/** Custom hook to fetch Formio submission data */ + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const queryClient = useQueryClient(); @@ -52,7 +44,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const query = useQuery({ queryKey: ["formio/2022/crf-submission", { id: rebateId }], queryFn: () => { - return getData(url).then((res) => { + return getData(url).then((res) => { const mongoId = res.submission?._id; const comboKey = res.submission?.data.bap_hidden_entity_combo_key; @@ -91,7 +83,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { return postData(url, updatedSubmission); }, onSuccess: (res) => { - return queryClient.setQueryData( + return queryClient.setQueryData( ["formio/2022/crf-submission", { id: rebateId }], (prevData) => { return prevData?.submission @@ -127,7 +119,6 @@ function CloseOutRequestForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); - const { rebateYear } = useRebateYearState(); const submissionsQueries = useSubmissionsQueries("2022"); const submissions = useSubmissions("2022"); @@ -195,8 +186,7 @@ function CloseOutRequestForm(props: { email: string }) { bap: rebate.crf.bap, }); - const crfSubmissionPeriodOpen = - configData.submissionPeriodOpen[rebateYear].crf; + const crfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].crf; const formIsReadOnly = (submission.state === "submitted" || !crfSubmissionPeriodOpen) && @@ -227,8 +217,8 @@ function CloseOutRequestForm(props: { email: string }) { submission.state === "draft" ? content.draftCRFIntro : submission.state === "submitted" - ? content.submittedCRFIntro - : "" + ? content.submittedCRFIntro + : "" } /> )} @@ -270,6 +260,7 @@ function CloseOutRequestForm(props: { email: string }) { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ + state: submission.state, data: { ...submission.data, last_updated_by: email, diff --git a/app/client/src/routes/frf2022.tsx b/app/client/src/routes/frf2022.tsx index 076e8f5d..6ad979d7 100644 --- a/app/client/src/routes/frf2022.tsx +++ b/app/client/src/routes/frf2022.tsx @@ -8,9 +8,12 @@ import clsx from "clsx"; import { cloneDeep, isEqual } from "lodash"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioFRF2022Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, postData, useContentData, @@ -26,21 +29,10 @@ import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; import { useDialogActions } from "@/contexts/dialog"; import { useNotificationsActions } from "@/contexts/notifications"; -import { useRebateYearState } from "@/contexts/rebateYear"; - -type ServerResponse = - | { - userAccess: false; - formSchema: null; - submission: null; - } - | { - userAccess: true; - formSchema: { url: string; json: object }; - submission: FormioFRF2022Submission; - }; - -/** Custom hook to fetch Formio submission data */ + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { const queryClient = useQueryClient(); @@ -53,7 +45,7 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { const query = useQuery({ queryKey: ["formio/2022/frf-submission", { id: mongoId }], queryFn: () => { - return getData(url).then((res) => { + return getData(url).then((res) => { const comboKey = res.submission?.data.bap_hidden_entity_combo_key; /** @@ -102,7 +94,7 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { return postData(url, updatedSubmission); }, onSuccess: (res) => { - return queryClient.setQueryData( + return queryClient.setQueryData( ["formio/2022/frf-submission", { id: mongoId }], (prevData) => { return prevData?.submission @@ -140,7 +132,6 @@ function FundingRequestForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); - const { rebateYear } = useRebateYearState(); const submissionsQueries = useSubmissionsQueries("2022"); const submissions = useSubmissions("2022"); @@ -330,8 +321,7 @@ function FundingRequestForm(props: { email: string }) { return null; } - const frfSubmissionPeriodOpen = - configData.submissionPeriodOpen[rebateYear].frf; + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].frf; const formIsReadOnly = (submission.state === "submitted" || !frfSubmissionPeriodOpen) && @@ -362,8 +352,8 @@ function FundingRequestForm(props: { email: string }) { submission.state === "draft" ? content.draftFRFIntro : submission.state === "submitted" - ? content.submittedFRFIntro - : "" + ? content.submittedFRFIntro + : "" } /> )} @@ -418,6 +408,7 @@ function FundingRequestForm(props: { email: string }) { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ + state: submission.state, data: { ...submission.data, last_updated_by: email, diff --git a/app/client/src/routes/frf2023.tsx b/app/client/src/routes/frf2023.tsx index e66dd0e5..f66b103e 100644 --- a/app/client/src/routes/frf2023.tsx +++ b/app/client/src/routes/frf2023.tsx @@ -8,9 +8,12 @@ import clsx from "clsx"; import { cloneDeep, isEqual } from "lodash"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioFRF2023Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, postData, useContentData, @@ -26,21 +29,10 @@ import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; import { useDialogActions } from "@/contexts/dialog"; import { useNotificationsActions } from "@/contexts/notifications"; -import { useRebateYearState } from "@/contexts/rebateYear"; - -type ServerResponse = - | { - userAccess: false; - formSchema: null; - submission: null; - } - | { - userAccess: true; - formSchema: { url: string; json: object }; - submission: FormioFRF2023Submission; - }; - -/** Custom hook to fetch Formio submission data */ + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { const queryClient = useQueryClient(); @@ -53,7 +45,7 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { const query = useQuery({ queryKey: ["formio/2023/frf-submission", { id: mongoId }], queryFn: () => { - return getData(url).then((res) => { + return getData(url).then((res) => { const comboKey = res.submission?.data._bap_entity_combo_key; /** @@ -88,7 +80,7 @@ function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { return postData(url, updatedSubmission); }, onSuccess: (res) => { - return queryClient.setQueryData( + return queryClient.setQueryData( ["formio/2023/frf-submission", { id: mongoId }], (prevData) => { return prevData?.submission @@ -126,7 +118,6 @@ function FundingRequestForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); - const { rebateYear } = useRebateYearState(); const submissionsQueries = useSubmissionsQueries("2023"); const submissions = useSubmissions("2023"); @@ -316,8 +307,7 @@ function FundingRequestForm(props: { email: string }) { return null; } - const frfSubmissionPeriodOpen = - configData.submissionPeriodOpen[rebateYear].frf; + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].frf; const formIsReadOnly = (submission.state === "submitted" || !frfSubmissionPeriodOpen) && @@ -348,8 +338,8 @@ function FundingRequestForm(props: { email: string }) { submission.state === "draft" ? content.draftFRFIntro : submission.state === "submitted" - ? content.submittedFRFIntro - : "" + ? content.submittedFRFIntro + : "" } /> )} @@ -404,6 +394,7 @@ function FundingRequestForm(props: { email: string }) { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ + state: submission.state, data: { ...submission.data, _user_email: email, diff --git a/app/client/src/routes/frf2024.tsx b/app/client/src/routes/frf2024.tsx new file mode 100644 index 00000000..caa48565 --- /dev/null +++ b/app/client/src/routes/frf2024.tsx @@ -0,0 +1,576 @@ +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 clsx from "clsx"; +import { cloneDeep, isEqual } from "lodash"; +import icons from "uswds/img/sprite.svg"; +// --- +import { + type FormioSchemaAndSubmission, + type FormioFRF2024Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { + 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 { useDialogActions } from "@/contexts/dialog"; +import { useNotificationsActions } from "@/contexts/notifications"; + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ +function useFormioSubmissionQueryAndMutation(mongoId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.resetQueries({ queryKey: ["formio/2024/frf-submission"] }); + }, [queryClient]); + + const url = `${serverUrl}/api/formio/2024/frf-submission/${mongoId}`; + + const query = useQuery({ + queryKey: ["formio/2024/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: { + formUrl: string; + [field: string]: unknown; + }) { + const s3Formio = cloneDeep(formio); + s3Formio.formUrl = `${serverUrl}/api/formio/2024/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/2024/frf-submission", { id: mongoId }], + (prevData) => { + return prevData?.submission + ? { ...prevData, submission: res } + : prevData; + }, + ); + }, + }); + + return { query, mutation }; +} + +export function FRF2024() { + 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 { displayDialog } = useDialogActions(); + const { + displayInfoNotification, + displaySuccessNotification, + displayErrorNotification, + dismissNotification, + } = useNotificationsActions(); + + const submissionsQueries = useSubmissionsQueries("2024"); + const submissions = useSubmissions("2024"); + + 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 frfNeedsEditsAndPRFExists = frfNeedsEdits && !!rebate?.prf.formio; + + /** + * NOTE: If the FRF submission needs edits and there's a corresponding PRF + * submission, display a confirmation dialog prompting the user to delete the + * PRF submission, as it's data will no longer valid when the FRF submission's + * data is changed. + */ + if (frfNeedsEditsAndPRFExists) { + displayDialog({ + dismissable: true, + heading: "Submission Edits Requested", + description: ( + <> +

+ This Application form submission has been opened at the request of + the applicant to make edits, but before you can make edits, the + associated Payment Request form submission needs to be deleted. If + the request to make edits to your Application form submission was + made in error, contact the Clean School Bus Program helpline at{" "} + cleanschoolbus@epa.gov. +

+ +

+ If you’d like to view the Payment Request form submission before + deletion, please close this dialog box, and you will be re-directed + to the associated Payment Request form. +

+ +

+ To proceed with deleting the associated Payment Request form + submission, please select the{" "} + Delete Payment Request Form Submission button + below, and the Payment Request form submission will be deleted. The + Application form will then be open for editing. +

+ +
+
+

+ Please note: Once deleted, the Payment Request + form submission will be removed from your dashboard and cannot + be recovered. +

+
+
+ + ), + confirmText: "Delete Payment Request Form Submission", + confirmedAction: () => { + const prf = rebate.prf.formio; + + if (!prf) { + displayErrorNotification({ + id: Date.now(), + body: ( + <> +

+ Error deleting Payment Request {rebate.rebateId}. +

+

+ Please notify the helpdesk that a problem exists preventing + the deletion of Payment Request form submission{" "} + {rebate.rebateId}. +

+ + ), + }); + + // NOTE: logging rebate for helpdesk debugging purposes + console.log(rebate); + return; + } + + displayInfoNotification({ + id: Date.now(), + body: ( +

+ Deleting Payment Request {rebate.rebateId}... +

+ ), + }); + + const url = `${serverUrl}/api/formio/2024/delete-prf-submission`; + + postData(url, { + mongoId: prf._id, + rebateId: prf.data._bap_rebate_id, + comboKey: prf.data._bap_entity_combo_key, + }) + .then((_res) => { + window.location.reload(); + }) + .catch((_err) => { + displayErrorNotification({ + id: Date.now(), + body: ( + <> +

+ Error deleting Payment Request {rebate.rebateId}. +

+

+ Please reload the page to attempt the deletion again, or + contact the helpdesk if the problem persists. +

+ + ), + }); + }); + }, + dismissedAction: () => navigate(`/prf/2024/${rebate.rebateId}`), + }); + + return null; + } + + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2024"].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} +
    +
  • + + {rebate?.frf.bap?.rebateId && ( +
  • +
    + +
    +
    + Rebate ID: {rebate.frf.bap.rebateId} +
    +
  • + )} +
+ + {}}> +
+
+
+ + + +
+
+
+ +
+ { + 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/frfNew.tsx b/app/client/src/routes/frfNew.tsx index e62cedcc..3f7a0c0a 100644 --- a/app/client/src/routes/frfNew.tsx +++ b/app/client/src/routes/frfNew.tsx @@ -5,11 +5,14 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import clsx from "clsx"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type RebateYear, type BapSamEntity, type FormioFRF2022Submission, type FormioFRF2023Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { postData, useContentData, useConfigData, @@ -26,7 +29,7 @@ import { useRebateYearState } from "@/contexts/rebateYear"; * Creates the initial FRF submission data for a given rebate year */ function createInitialSubmissionData(options: { - rebateYear: "2022" | "2023"; + rebateYear: RebateYear; email: string; entity: BapSamEntity; }) { @@ -69,29 +72,29 @@ function createInitialSubmissionData(options: { sam_hidden_applicant_state: PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c, sam_hidden_applicant_zip_code: PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c, } - : rebateYear === "2023" - ? { - _user_email: email, - _user_title: title, - _user_name: name, - _bap_entity_combo_key: ENTITY_COMBO_KEY__c, - _bap_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, - _bap_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, - _bap_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, - _bap_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, - _bap_applicant_email: email, - _bap_applicant_title: title, - _bap_applicant_name: name, - _bap_applicant_efti: ENTITY_EFT_INDICATOR__c, - _bap_applicant_uei: UNIQUE_ENTITY_ID__c, - _bap_applicant_organization_name: LEGAL_BUSINESS_NAME__c, - _bap_applicant_street_address_1: PHYSICAL_ADDRESS_LINE_1__c, - _bap_applicant_street_address_2: PHYSICAL_ADDRESS_LINE_2__c, - _bap_applicant_city: PHYSICAL_ADDRESS_CITY__c, - _bap_applicant_state: PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c, - _bap_applicant_zip: PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c, - } - : null; + : rebateYear === "2023" || rebateYear === "2024" + ? { + _user_email: email, + _user_title: title, + _user_name: name, + _bap_entity_combo_key: ENTITY_COMBO_KEY__c, + _bap_elec_bus_poc_email: ELEC_BUS_POC_EMAIL__c, + _bap_alt_elec_bus_poc_email: ALT_ELEC_BUS_POC_EMAIL__c, + _bap_govt_bus_poc_email: GOVT_BUS_POC_EMAIL__c, + _bap_alt_govt_bus_poc_email: ALT_GOVT_BUS_POC_EMAIL__c, + _bap_applicant_email: email, + _bap_applicant_title: title, + _bap_applicant_name: name, + _bap_applicant_efti: ENTITY_EFT_INDICATOR__c, + _bap_applicant_uei: UNIQUE_ENTITY_ID__c, + _bap_applicant_organization_name: LEGAL_BUSINESS_NAME__c, + _bap_applicant_street_address_1: PHYSICAL_ADDRESS_LINE_1__c, + _bap_applicant_street_address_2: PHYSICAL_ADDRESS_LINE_2__c, + _bap_applicant_city: PHYSICAL_ADDRESS_CITY__c, + _bap_applicant_state: PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c, + _bap_applicant_zip: PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c, + } + : null; } export function FRFNew() { @@ -125,9 +128,33 @@ export function FRFNew() { const frfSubmissionPeriodOpen = configData.submissionPeriodOpen[rebateYear].frf; - const activeSamEntities = bapSamData.entities.filter((entity) => { - return entity.ENTITY_STATUS__c === "Active"; - }); + const samEntities = bapSamData.entities.reduce( + (object, entity) => { + const { + ENTITY_STATUS__c, + EXCLUSION_STATUS_FLAG__c, + DEBT_SUBJECT_TO_OFFSET_FLAG__c, + } = entity; + + const isActive = ENTITY_STATUS__c === "Active"; + const hasExclusionStatus = EXCLUSION_STATUS_FLAG__c === "D"; + const hasDebtSubjectToOffset = DEBT_SUBJECT_TO_OFFSET_FLAG__c === "Y"; + + const isEligible = !hasExclusionStatus && !hasDebtSubjectToOffset; + + if (isActive && isEligible) object.eligible.push(entity); + if (isActive && !isEligible) object.ineligible.push(entity); + + return object; + }, + { + eligible: [] as BapSamEntity[], + ineligible: [] as BapSamEntity[], + }, + ); + + const totalActiveSamEntities = + samEntities.eligible.length + samEntities.ineligible.length; return ( @@ -211,7 +238,7 @@ export function FRFNew() {
- ) : activeSamEntities.length <= 0 ? ( + ) : totalActiveSamEntities === 0 ? (
- {activeSamEntities.map((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; + {samEntities.eligible.map((entity) => { + const { + ENTITY_COMBO_KEY__c, + UNIQUE_ENTITY_ID__c, + ENTITY_EFT_INDICATOR__c, + LEGAL_BUSINESS_NAME__c, + } = entity; return ( - + - New Application with UEI: {uei} and - EFTI: {efti} + New Application with UEI:{" "} + {UNIQUE_ENTITY_ID__c} and EFTI:{" "} + {ENTITY_EFT_INDICATOR__c} New Application - {postingDataId === comboKey && ( + {postingDataId === + ENTITY_COMBO_KEY__c && ( )} - {uei} - {efti || "0000"} + {UNIQUE_ENTITY_ID__c} + + + {ENTITY_EFT_INDICATOR__c || "0000"} + + + {LEGAL_BUSINESS_NAME__c} - {orgName} ); })} + + {samEntities.ineligible.length > 0 && ( + <> + + + + Ineligible SAM.gov Entities: + +
+ The following SAM.gov entities are + ineligible due to their exclusion status or + a debt subject to offset. Please visit + SAM.gov to resolve these issues. + + + + {samEntities.ineligible.map((entity) => { + const { + ENTITY_COMBO_KEY__c, + UNIQUE_ENTITY_ID__c, + ENTITY_EFT_INDICATOR__c, + LEGAL_BUSINESS_NAME__c, + } = entity; + + return ( + + + + + + {UNIQUE_ENTITY_ID__c} + + + {ENTITY_EFT_INDICATOR__c || "0000"} + + + {LEGAL_BUSINESS_NAME__c} + + + ); + })} + + )}
diff --git a/app/client/src/routes/helpdesk.tsx b/app/client/src/routes/helpdesk.tsx index d7792290..a57b606c 100644 --- a/app/client/src/routes/helpdesk.tsx +++ b/app/client/src/routes/helpdesk.tsx @@ -1,11 +1,27 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { + type UseMutationResult, + useQueryClient, + useQuery, + useMutation, +} from "@tanstack/react-query"; import { Form } from "@formio/react"; import clsx from "clsx"; import icon from "uswds/img/usa-icons-bg/search--white.svg"; import icons from "uswds/img/sprite.svg"; // --- +import { + type RebateYear, + type FormType, + type BapSubmissionData, + type FormioFRF2022Submission, + type FormioPRF2022Submission, + type FormioCRF2022Submission, + type FormioFRF2023Submission, + type FormioPRF2023Submission, + // type FormioCRF2023Submission +} from "@/types"; import { serverUrl, messages, @@ -15,13 +31,8 @@ import { formioEmailField, } from "@/config"; import { - type FormType, - type FormioFRF2022Submission, - type FormioPRF2022Submission, - type FormioCRF2022Submission, - type FormioFRF2023Submission, - type BapSubmission, getData, + postData, useContentData, useHelpdeskAccess, submissionNeedsEdits, @@ -30,17 +41,17 @@ import { Loading, LoadingButtonIcon } from "@/components/loading"; import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; import { TextWithTooltip } from "@/components/tooltip"; +import { useDialogActions } from "@/contexts/dialog"; import { - type RebateYear, useRebateYearState, useRebateYearActions, } from "@/contexts/rebateYear"; -type ServerResponse = +type Response = | { formSchema: null; formio: null; - bap: BapSubmission; + bap: BapSubmissionData; } | { formSchema: { url: string; json: object }; @@ -48,8 +59,9 @@ type ServerResponse = | FormioFRF2022Submission | FormioPRF2022Submission | FormioCRF2022Submission - | FormioFRF2023Submission; - bap: BapSubmission; + | FormioFRF2023Submission + | FormioPRF2023Submission; + bap: BapSubmissionData; }; type SubmissionAction = { @@ -75,6 +87,12 @@ type SubmissionAction = { modified: string; // ISO 8601 date time string }; +type DraftSubmission = { + data: { [field: string]: unknown }; + metadata: { [field: string]: unknown }; + state: "draft"; +}; + /** * Formio action mapping (practically, just capitalizes "save" or "email"). */ @@ -91,19 +109,37 @@ function formatTime(dateTimeString: string | null) { } function ResultTableRow(props: { + setFormDisplayed: Dispatch>; setActionsData: Dispatch< SetStateAction<{ fetched: boolean; results: SubmissionAction[] }> >; + submissionMutation: UseMutationResult< + Response["formio"], + unknown, + DraftSubmission, + unknown + >; lastSearchedText: string; formType: FormType; formio: | FormioFRF2022Submission | FormioPRF2022Submission | FormioCRF2022Submission - | FormioFRF2023Submission; - bap: BapSubmission; + | FormioFRF2023Submission + | FormioPRF2023Submission; + bap: BapSubmissionData; }) { - const { setActionsData, lastSearchedText, formType, formio, bap } = props; + const { + setFormDisplayed, + setActionsData, + submissionMutation, + lastSearchedText, + formType, + formio, + bap, + } = props; + + const { displayDialog } = useDialogActions(); const { rebateYear } = useRebateYearState(); const formId = formio.form; @@ -161,9 +197,119 @@ function ResultTableRow(props: { const email = (formio.data[emailField] as string) || ""; return ( - <> + + + + {bapId || mongoId} - {status} + + {status} + + {!bapId && status === "Submitted" && ( + + + + )} + {name} {email} @@ -213,7 +359,7 @@ function ResultTableRow(props: { - + ); } @@ -244,11 +390,29 @@ export function Helpdesk() { const submissionQuery = useQuery({ queryKey: ["helpdesk/submission"], - queryFn: () => getData(submissionUrl), + queryFn: () => getData(submissionUrl), onSuccess: (_res) => setResultDisplayed(true), enabled: false, }); + const submissionMutation = useMutation({ + mutationFn: (submission: DraftSubmission) => { + return postData(submissionUrl, submission); + }, + onSuccess: (res) => { + queryClient.setQueryData( + ["helpdesk/submission"], + (prevData) => { + return prevData?.formio + ? { ...prevData, formio: { ...prevData.formio, submission: res } } + : prevData; + }, + ); + + submissionQuery.refetch(); + }, + }); + const { formSchema, formio, bap } = submissionQuery.data ?? {}; if (helpdeskAccess === "pending") { @@ -292,6 +456,7 @@ export function Helpdesk() { > +
@@ -486,34 +651,15 @@ export function Helpdesk() { - - - - - - - +
@@ -595,7 +741,10 @@ export function Helpdesk() { diff --git a/app/client/src/routes/prf2022.tsx b/app/client/src/routes/prf2022.tsx index 8355763f..af77b27d 100644 --- a/app/client/src/routes/prf2022.tsx +++ b/app/client/src/routes/prf2022.tsx @@ -8,9 +8,12 @@ import clsx from "clsx"; import { cloneDeep, isEqual } from "lodash"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioPRF2022Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, postData, useContentData, @@ -25,21 +28,10 @@ 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: FormioPRF2022Submission; - }; - -/** Custom hook to fetch Formio submission data */ + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const queryClient = useQueryClient(); @@ -52,7 +44,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const query = useQuery({ queryKey: ["formio/2022/prf-submission", { id: rebateId }], queryFn: () => { - return getData(url).then((res) => { + return getData(url).then((res) => { const mongoId = res.submission?._id; const comboKey = res.submission?.data.bap_hidden_entity_combo_key; @@ -91,7 +83,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { return postData(url, updatedSubmission); }, onSuccess: (res) => { - return queryClient.setQueryData( + return queryClient.setQueryData( ["formio/2022/prf-submission", { id: rebateId }], (prevData) => { return prevData?.submission @@ -127,7 +119,6 @@ function PaymentRequestForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); - const { rebateYear } = useRebateYearState(); const submissionsQueries = useSubmissionsQueries("2022"); const submissions = useSubmissions("2022"); @@ -202,8 +193,7 @@ function PaymentRequestForm(props: { email: string }) { bap: rebate.prf.bap, }); - const prfSubmissionPeriodOpen = - configData.submissionPeriodOpen[rebateYear].prf; + const prfSubmissionPeriodOpen = configData.submissionPeriodOpen["2022"].prf; const formIsReadOnly = frfNeedsEdits || @@ -244,8 +234,8 @@ function PaymentRequestForm(props: { email: string }) { submission.state === "draft" ? content.draftPRFIntro : submission.state === "submitted" - ? content.submittedPRFIntro - : "" + ? content.submittedPRFIntro + : "" } /> )} @@ -291,6 +281,7 @@ function PaymentRequestForm(props: { email: string }) { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ + state: submission.state, data: { ...submission.data, last_updated_by: email, diff --git a/app/client/src/routes/prf2023.tsx b/app/client/src/routes/prf2023.tsx index 1f4b6a71..55fc4a16 100644 --- a/app/client/src/routes/prf2023.tsx +++ b/app/client/src/routes/prf2023.tsx @@ -8,9 +8,12 @@ import clsx from "clsx"; import { cloneDeep, isEqual } from "lodash"; import icons from "uswds/img/sprite.svg"; // --- -import { serverUrl, messages } from "@/config"; import { + type FormioSchemaAndSubmission, type FormioPRF2023Submission, +} from "@/types"; +import { serverUrl, messages } from "@/config"; +import { getData, postData, useContentData, @@ -25,21 +28,10 @@ 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: FormioPRF2023Submission; - }; - -/** Custom hook to fetch Formio submission data */ + +type Response = FormioSchemaAndSubmission; + +/** Custom hook to fetch and update Formio submission data */ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const queryClient = useQueryClient(); @@ -52,7 +44,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { const query = useQuery({ queryKey: ["formio/2023/prf-submission", { id: rebateId }], queryFn: () => { - return getData(url).then((res) => { + return getData(url).then((res) => { const mongoId = res.submission?._id; const comboKey = res.submission?.data._bap_entity_combo_key; @@ -91,7 +83,7 @@ function useFormioSubmissionQueryAndMutation(rebateId: string | undefined) { return postData(url, updatedSubmission); }, onSuccess: (res) => { - return queryClient.setQueryData( + return queryClient.setQueryData( ["formio/2023/prf-submission", { id: rebateId }], (prevData) => { return prevData?.submission @@ -127,7 +119,6 @@ function PaymentRequestForm(props: { email: string }) { displayErrorNotification, dismissNotification, } = useNotificationsActions(); - const { rebateYear } = useRebateYearState(); const submissionsQueries = useSubmissionsQueries("2023"); const submissions = useSubmissions("2023"); @@ -202,8 +193,7 @@ function PaymentRequestForm(props: { email: string }) { bap: rebate.prf.bap, }); - const prfSubmissionPeriodOpen = - configData.submissionPeriodOpen[rebateYear].prf; + const prfSubmissionPeriodOpen = configData.submissionPeriodOpen["2023"].prf; const formIsReadOnly = frfNeedsEdits || @@ -242,8 +232,8 @@ function PaymentRequestForm(props: { email: string }) { submission.state === "draft" ? content.draftPRFIntro : submission.state === "submitted" - ? content.submittedPRFIntro - : "" + ? content.submittedPRFIntro + : "" } /> )} @@ -289,6 +279,7 @@ function PaymentRequestForm(props: { email: string }) { form={formSchema.json} url={formSchema.url} // NOTE: used for file uploads submission={{ + state: submission.state, data: { ...submission.data, _user_email: email, diff --git a/app/client/src/routes/submissions.tsx b/app/client/src/routes/submissions.tsx index 0e5c725a..eea9d6df 100644 --- a/app/client/src/routes/submissions.tsx +++ b/app/client/src/routes/submissions.tsx @@ -9,6 +9,12 @@ import { ChevronUpIcon } from "@heroicons/react/20/solid"; import clsx from "clsx"; import icons from "uswds/img/sprite.svg"; // --- +import { + type RebateYear, + type Rebate2022, + type Rebate2023, + type Rebate2024, +} from "@/types"; import { serverUrl, messages, @@ -17,19 +23,12 @@ import { statusIconMap, } from "@/config"; import { - type FormioFRF2022Submission, - type FormioPRF2022Submission, - type FormioCRF2022Submission, - type FormioFRF2023Submission, - type FormioPRF2023Submission, - // type FormioCRF2023Submission, - type Rebate, postData, useContentData, useConfigData, useBapSamData, useChangeRequestsQuery, - useChangeRequestsData, + useChangeRequests, useSubmissionsQueries, useSubmissions, submissionNeedsEdits, @@ -41,9 +40,9 @@ import { Message } from "@/components/message"; import { MarkdownContent } from "@/components/markdownContent"; import { TextWithTooltip } from "@/components/tooltip"; import { ChangeRequest2023Button } from "@/components/change2023New"; +import { ChangeRequest2024Button } from "@/components/change2024New"; import { useNotificationsActions } from "@/contexts/notifications"; import { - type RebateYear, useRebateYearState, useRebateYearActions, } from "@/contexts/rebateYear"; @@ -153,20 +152,22 @@ function SubmissionsTableHeader(props: { rebateYear: RebateYear }) { /> - {rebateYear === "2023" && ( + {rebateYear === "2023" || rebateYear === "2024" ? ( - )} + ) : null} ); } -function FRF2022Submission(props: { rebate: Rebate }) { +/* --- 2022 Submissions --- */ + +function FRF2022Submission(props: { rebate: Rebate2022 }) { const { rebate } = props; const { frf, prf, crf } = rebate; @@ -183,7 +184,7 @@ function FRF2022Submission(props: { rebate: Rebate }) { applicantOrganizationName, schoolDistrictName, last_updated_by, - } = (frf.formio as FormioFRF2022Submission).data; + } = frf.formio.data; const date = new Date(frf.formio.modified).toLocaleDateString(); const time = new Date(frf.formio.modified).toLocaleTimeString(); @@ -380,7 +381,7 @@ save the form for the EFT indicator to be displayed. */ ); } -function PRF2022Submission(props: { rebate: Rebate }) { +function PRF2022Submission(props: { rebate: Rebate2022 }) { const { rebate } = props; const { frf, prf, crf } = rebate; @@ -403,8 +404,7 @@ function PRF2022Submission(props: { rebate: Rebate }) { /** matched SAM.gov entity for the FRF submission */ const entity = bapSamData.entities.find((entity) => { const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; - const comboKey = (frf.formio as FormioFRF2022Submission).data - .bap_hidden_entity_combo_key; + const comboKey = frf.formio.data.bap_hidden_entity_combo_key; return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; }); @@ -441,7 +441,7 @@ function PRF2022Submission(props: { rebate: Rebate }) { comboKey: frf.bap.comboKey, rebateId: frf.bap.rebateId, // CSB Rebate ID (6 digits) frfReviewItemId: frf.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) - frfFormModified: frf.bap.modified, + frfModified: frf.bap.modified, }) .then((_res) => { navigate(`/prf/2022/${frf.bap?.rebateId}`); @@ -496,10 +496,7 @@ function PRF2022Submission(props: { rebate: Rebate }) { // 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 as FormioPRF2022Submission).data; + 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(); @@ -594,7 +591,7 @@ function PRF2022Submission(props: { rebate: Rebate }) { ); } -function CRF2022Submission(props: { rebate: Rebate }) { +function CRF2022Submission(props: { rebate: Rebate2022 }) { const { rebate } = props; const { frf, prf, crf } = rebate; @@ -617,8 +614,7 @@ function CRF2022Submission(props: { rebate: Rebate }) { /** matched SAM.gov entity for the PRF submission */ const entity = bapSamData.entities.find((entity) => { const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; - const comboKey = (prf.formio as FormioPRF2022Submission | null)?.data - .bap_hidden_entity_combo_key; + const comboKey = prf.formio?.data.bap_hidden_entity_combo_key; return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; }); @@ -710,10 +706,7 @@ function CRF2022Submission(props: { rebate: Rebate }) { // 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 as FormioCRF2022Submission).data; + 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(); @@ -814,7 +807,220 @@ function CRF2022Submission(props: { rebate: Rebate }) { ); } -function FRF2023Submission(props: { rebate: Rebate }) { +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; + })} + +
+   +
+
+ + ); +} + +/* --- 2023 Submissions --- */ + +function ChangeRequests2023() { + const changeRequestsQuery = useChangeRequestsQuery("2023"); + const changeRequests = useChangeRequests("2023"); + + if (changeRequestsQuery.isFetching) { + return ; + } + + if (!changeRequests || changeRequests.length === 0) return null; + + return ( +
+ + + Your Change Requests + + + +
+ + + + + + + + + + + + + + + + {changeRequests.map((request, index) => { + const { _id, modified, data } = request; + const { + _request_form, + _bap_rebate_id, + _mongo_id, + _user_email, + request_type, + } = data; + + const date = new Date(modified).toLocaleDateString(); + const time = new Date(modified).toLocaleTimeString(); + + const formType = + _request_form === "frf" + ? "Application" + : _request_form === "prf" + ? "Payment Request" + : _request_form === "crf" + ? "Close Out" + : ""; + + return ( + + + + + + + + + + + + + + ); + })} + +
+ + + + + + + + + +
+ + {_bap_rebate_id || _mongo_id} + + + {formType} + + {request_type?.label} + {_user_email} + {date} +
+
+
+ ); +} + +function FRF2023Submission(props: { rebate: Rebate2023 }) { const { rebate } = props; const { frf, prf, crf } = rebate; @@ -828,8 +1034,7 @@ function FRF2023Submission(props: { rebate: Rebate }) { /** matched SAM.gov entity for the FRF submission */ const entity = bapSamData.entities.find((entity) => { const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; - const comboKey = (frf.formio as FormioFRF2023Submission).data - ._bap_entity_combo_key; + const comboKey = frf.formio.data._bap_entity_combo_key; return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; }); @@ -846,7 +1051,7 @@ function FRF2023Submission(props: { rebate: Rebate }) { appInfo_efti, appInfo_orgName, _formio_schoolDistrictName, - } = (frf.formio as FormioFRF2023Submission).data; + } = frf.formio.data; const date = new Date(frf.formio.modified).toLocaleDateString(); const time = new Date(frf.formio.modified).toLocaleTimeString(); @@ -1032,7 +1237,7 @@ handle when it's value is an empty string. */} ); } -function PRF2023Submission(props: { rebate: Rebate }) { +function PRF2023Submission(props: { rebate: Rebate2023 }) { const { rebate } = props; const { frf, prf, crf } = rebate; @@ -1055,8 +1260,7 @@ function PRF2023Submission(props: { rebate: Rebate }) { /** matched SAM.gov entity for the FRF submission */ const entity = bapSamData.entities.find((entity) => { const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; - const comboKey = (frf.formio as FormioFRF2023Submission).data - ._bap_entity_combo_key; + const comboKey = frf.formio.data._bap_entity_combo_key; return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; }); @@ -1093,7 +1297,7 @@ function PRF2023Submission(props: { rebate: Rebate }) { comboKey: frf.bap.comboKey, rebateId: frf.bap.rebateId, // CSB Rebate ID (6 digits) frfReviewItemId: frf.bap.reviewItemId, // CSB Rebate ID with form/version ID (9 digits) - frfFormModified: frf.bap.modified, + frfModified: frf.bap.modified, }) .then((_res) => { navigate(`/prf/2023/${frf.bap?.rebateId}`); @@ -1148,11 +1352,8 @@ function PRF2023Submission(props: { rebate: Rebate }) { // return if a Payment Request submission has not been created for this rebate if (!prf.formio) return null; - const { - _user_email, - _bap_entity_combo_key, - _bap_rebate_id, // - } = (prf.formio as FormioPRF2023Submission).data; + const { _user_email, _bap_entity_combo_key, _bap_rebate_id } = + prf.formio.data; const date = new Date(prf.formio.modified).toLocaleDateString(); const time = new Date(prf.formio.modified).toLocaleTimeString(); @@ -1262,20 +1463,27 @@ function PRF2023Submission(props: { rebate: Rebate }) { ); } -// function CRF2023Submission(props: { rebate: Rebate }) { +// function CRF2023Submission(props: { rebate: Rebate2023 }) { // // // } -function Submissions2022() { +function Submissions2023() { const content = useContentData(); - const submissionsQueries = useSubmissionsQueries("2022"); - const submissions = useSubmissions("2022"); + const changeRequestsQuery = useChangeRequestsQuery("2023"); + const submissionsQueries = useSubmissionsQueries("2023"); + const submissions = useSubmissions("2023"); - if (submissionsQueries.some((query) => query.isFetching)) { + if ( + changeRequestsQuery.isInitialLoading || + submissionsQueries.some((query) => query.isFetching) + ) { return ; } - if (submissionsQueries.some((query) => query.isError)) { + if ( + changeRequestsQuery.isError || + submissionsQueries.some((query) => query.isError) + ) { return ; } @@ -1289,6 +1497,8 @@ function Submissions2022() { return ( <> + + {content && ( - + {submissions.map((rebate, index) => { - return rebate.rebateYear === "2022" ? ( + return rebate.rebateYear === "2023" ? ( - - - + + + {/* */} {/* blank row after all submissions but the last one */} {index !== submissions.length - 1 && ( @@ -1331,83 +1541,11 @@ function Submissions2022() { ); } -function Submissions2023() { - const content = useContentData(); - const changeRequestsQuery = useChangeRequestsQuery("2023"); - const submissionsQueries = useSubmissionsQueries("2023"); - const submissions = useSubmissions("2023"); - - if ( - changeRequestsQuery.isInitialLoading || - submissionsQueries.some((query) => query.isFetching) - ) { - return ; - } - - if ( - changeRequestsQuery.isError || - 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; - })} - -
-   -
-
- - ); -} +/* --- 2024 Submissions --- */ -function ChangeRequests2023() { - const changeRequestsQuery = useChangeRequestsQuery("2023"); - const changeRequests = useChangeRequestsData("2023"); +function ChangeRequests2024() { + const changeRequestsQuery = useChangeRequestsQuery("2024"); + const changeRequests = useChangeRequests("2024"); if (changeRequestsQuery.isFetching) { return ; @@ -1551,6 +1689,292 @@ function ChangeRequests2023() { ); } +function FRF2024Submission(props: { rebate: Rebate2024 }) { + const { rebate } = props; + const { frf, prf, crf } = rebate; + + const { email } = useOutletContext<{ email: string }>(); + + const configData = useConfigData(); + const bapSamData = useBapSamData(); + + if (!configData || !bapSamData) return null; + + /** matched SAM.gov entity for the FRF submission */ + const entity = bapSamData.entities.find((entity) => { + const { ENTITY_STATUS__c, ENTITY_COMBO_KEY__c } = entity; + const comboKey = frf.formio.data._bap_entity_combo_key; + return ENTITY_STATUS__c === "Active" && ENTITY_COMBO_KEY__c === comboKey; + }); + + if (!entity) return null; + + const { title, name } = getUserInfo(email, entity); + + const frfSubmissionPeriodOpen = configData.submissionPeriodOpen["2024"].frf; + + const { + _user_email, + _bap_entity_combo_key, + appInfo_uei, + appInfo_efti, + appInfo_organization_name, + _formio_schoolDistrictName, + } = frf.formio.data; + + const date = new Date(frf.formio.modified).toLocaleDateString(); + const time = new Date(frf.formio.modified).toLocaleTimeString(); + + const frfNeedsEdits = submissionNeedsEdits({ + formio: frf.formio, + bap: frf.bap, + }); + + const frfBapInternalStatus = frf.bap?.status || ""; + const frfFormioStatus = formioStatusMap.get(frf.formio.state); + + const frfStatus = frfNeedsEdits + ? "Edits Requested" + : bapStatusMap["2024"].frf.get(frfBapInternalStatus) || + frfFormioStatus || + ""; + + const frfSelected = frfStatus === "Selected"; + const frfSelectedButNoPRF = frfSelected && !Boolean(prf.formio); + + const prfApproved = prf.bap?.status === "Accepted"; + const prfApprovedButNoCRF = prfApproved && !Boolean(crf.formio); + + const statusTableCellClassNames = + frfFormioStatus === "Submitted" || !frfSubmissionPeriodOpen + ? "text-italic" + : ""; + + const frfUrl = `/frf/2024/${frf.formio._id}`; + + /** + * NOTE on the usage of `TextWithTooltip` below: + * When a form is first initially created, and the user has not yet clicked + * the "Next" or "Save" buttons, any fields that the Formio form definition + * sets automatically (based on hidden fields we inject on form creation) will + * not yet be part of the form submission data. As soon as the user clicks the + * "Next" or "Save" buttons the first time, those fields will be set and + * stored in the submission. Since we display some of those fields in the + * table below, we need to check if their values exist, and if they don't (for + * cases where the user has not yet advanced past the first screen of the + * form...which we believe is a bit of an edge case, as most users will likely + * do that after starting a new application), indicate to the user they need + * to first save the form for the fields to be displayed. + */ + return ( + + + {frfNeedsEdits ? ( + + ) : frf.formio.state === "submitted" || !frfSubmissionPeriodOpen ? ( + + ) : frf.formio.state === "draft" ? ( + + ) : null} + + + + {frf.bap?.rebateId ? ( + + {frf.bap.rebateId} + + ) : ( + + )} + + + + Application +
+ + {frfStatus === "Needs Clarification" ? ( + + ) : ( + <> + + {frfStatus} + + )} + + + + + <> + {Boolean(appInfo_uei) ? ( + appInfo_uei + ) : ( + + )} +
+ {Boolean(appInfo_efti) ? ( + appInfo_efti + ) : ( + + )} + + + + + <> + {Boolean(appInfo_organization_name) ? ( + appInfo_organization_name + ) : ( + + )} +
+ {Boolean(_formio_schoolDistrictName) ? ( + _formio_schoolDistrictName + ) : ( + + )} + + + + + {_user_email} +
+ {date} + + + + + + + ); +} + +// function PRF2024Submission(props: { rebate: Rebate2024 }) { +// // +// } + +// function CRF2024Submission(props: { rebate: Rebate2024 }) { +// // +// } + +function Submissions2024() { + const content = useContentData(); + const changeRequestsQuery = useChangeRequestsQuery("2024"); + const submissionsQueries = useSubmissionsQueries("2024"); + const submissions = useSubmissions("2024"); + + if ( + changeRequestsQuery.isInitialLoading || + submissionsQueries.some((query) => query.isFetching) + ) { + return ; + } + + if ( + changeRequestsQuery.isError || + submissionsQueries.some((query) => query.isError) + ) { + return ; + } + + if (submissions.length === 0) { + return ( +
+ +
+ ); + } + + return ( + <> + + + {content && ( + + )} + +
+ + + + {submissions.map((rebate, index) => { + return rebate.rebateYear === "2024" ? ( + + + {/* */} + {/* */} + {/* blank row after all submissions but the last one */} + {index !== submissions.length - 1 && ( + + + + )} + + ) : null; + })} + +
+   +
+
+ + ); +} + +/* --- Submissions --- */ + export function Submissions() { const content = useContentData(); const configData = useConfigData(); @@ -1596,6 +2020,7 @@ export function Submissions() { > + @@ -1615,6 +2040,7 @@ export function Submissions() { {rebateYear === "2022" && } {rebateYear === "2023" && } + {rebateYear === "2024" && } {content && ( = + | { + userAccess: false; + formSchema: null; + submission: null; + } + | { + userAccess: true; + formSchema: { url: string; json: object }; + submission: Submission; + }; + +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 FormioPRF2023Submission = FormioSubmission & { + data: FormioPRF2023Data; +}; + +export type FormioCRF2023Submission = FormioSubmission & { + data: FormioCRF2023Data; +}; + +export type FormioChange2023Submission = FormioSubmission & { + data: FormioChange2023Data; +}; + +export type FormioFRF2024Submission = FormioSubmission & { + data: FormioFRF2024Data; +}; + +export type FormioPRF2024Submission = FormioSubmission & { + data: FormioPRF2024Data; +}; + +export type FormioCRF2024Submission = FormioSubmission & { + data: FormioCRF2024Data; +}; + +export type FormioChange2024Submission = FormioSubmission & { + data: FormioChange2024Data; +}; + +export type Rebate2022 = { + rebateYear: "2022"; + frf: { + formio: FormioFRF2022Submission; + bap: BapSubmissionData | null; + }; + prf: { + formio: FormioPRF2022Submission | null; + bap: BapSubmissionData | null; + }; + crf: { + formio: FormioCRF2022Submission | null; + bap: BapSubmissionData | null; + }; +}; + +export type Rebate2023 = { + rebateYear: "2023"; + frf: { + formio: FormioFRF2023Submission; + bap: BapSubmissionData | null; + }; + prf: { + formio: FormioPRF2023Submission | null; + bap: BapSubmissionData | null; + }; + crf: { + formio: FormioCRF2023Submission | null; + bap: BapSubmissionData | null; + }; +}; + +export type Rebate2024 = { + rebateYear: "2024"; + frf: { + formio: FormioFRF2024Submission; + bap: BapSubmissionData | null; + }; + prf: { + formio: FormioPRF2024Submission | null; + bap: BapSubmissionData | null; + }; + crf: { + formio: FormioCRF2024Submission | null; + bap: BapSubmissionData | null; + }; +}; diff --git a/app/client/src/utilities.ts b/app/client/src/utilities.ts index 04716c14..c50852d9 100644 --- a/app/client/src/utilities.ts +++ b/app/client/src/utilities.ts @@ -1,302 +1,67 @@ import { useEffect } from "react"; -import { useQueryClient, useQuery, useQueries } from "@tanstack/react-query"; +import { + type UseQueryOptions, + type UseQueryResult, + useQueryClient, + useQuery, + useQueries, +} from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; // --- -import { serverUrl } from "@/config"; - -type RebateYear = "2022" | "2023"; - -type Content = { - siteAlert: string; - helpdeskIntro: string; - allRebatesIntro: string; - allRebatesOutro: string; - newFRFDialog: string; - draftFRFIntro: string; - submittedFRFIntro: string; - draftPRFIntro: string; - submittedPRFIntro: string; - draftCRFIntro: string; - submittedCRFIntro: string; - newChangeIntro: string; - submittedChangeIntro: string; -}; - -type UserData = { - mail: string; - memberof: string; - exp: number; -}; - -type ConfigData = { - submissionPeriodOpen: { - 2022: { frf: boolean; prf: boolean; crf: boolean }; - 2023: { frf: boolean; prf: boolean; crf: boolean }; - }; -}; - -export type BapSamEntity = { - Id: string; - ENTITY_COMBO_KEY__c: string; - UNIQUE_ENTITY_ID__c: string; - ENTITY_EFT_INDICATOR__c: string; - ENTITY_STATUS__c: "Active" | string; - LEGAL_BUSINESS_NAME__c: string; - PHYSICAL_ADDRESS_LINE_1__c: string; - PHYSICAL_ADDRESS_LINE_2__c: string | null; - PHYSICAL_ADDRESS_CITY__c: string; - PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c: string; - PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c: string; - PHYSICAL_ADDRESS_ZIP_CODE_4__c: string; - // contacts - ELEC_BUS_POC_EMAIL__c: string | null; - ELEC_BUS_POC_NAME__c: string | null; - ELEC_BUS_POC_TITLE__c: string | null; - // - ALT_ELEC_BUS_POC_EMAIL__c: string | null; - ALT_ELEC_BUS_POC_NAME__c: string | null; - ALT_ELEC_BUS_POC_TITLE__c: string | null; - // - GOVT_BUS_POC_EMAIL__c: string | null; - GOVT_BUS_POC_NAME__c: string | null; - GOVT_BUS_POC_TITLE__c: string | null; - // - ALT_GOVT_BUS_POC_EMAIL__c: string | null; - ALT_GOVT_BUS_POC_NAME__c: string | null; - ALT_GOVT_BUS_POC_TITLE__c: string | null; - // - attributes: { type: string; url: string }; -}; - -export type BapSamData = - | { results: false; entities: [] } - | { results: true; entities: BapSamEntity[] }; - -type BapFormSubmission = { - UEI_EFTI_Combo_Key__c: string; // UEI + EFTI combo key - CSB_Form_ID__c: string; // MongoDB ObjectId string - CSB_Modified_Full_String__c: string; // ISO 8601 date time string - CSB_Review_Item_ID__c: string; // CSB Rebate ID with form/version ID (9 digits) - Parent_Rebate_ID__c: string; // CSB Rebate ID (6 digits) - Record_Type_Name__c: /* - * NOTE: 2022 submissions don't have a year in their record type name, but - * we'll account for it here in case the BAP switches to using it in the future. - */ - | "CSB Funding Request" // NOTE: 2022 submissions - | "CSB Payment Request" // NOTE: 2022 submissions - | "CSB Close Out Request" // NOTE: 2022 submissions - | "CSB Funding Request 2022" // NOTE: not currently used - | "CSB Payment Request 2022" // NOTE: not currently used - | "CSB Close Out Request 2022" // NOTE: not currently used - | "CSB Funding Request 2023" - | "CSB Payment Request 2023" - | "CSB Close Out Request 2023"; - Rebate_Program_Year__c: null | RebateYear; - Parent_CSB_Rebate__r: { - CSB_Funding_Request_Status__c: string; - CSB_Payment_Request_Status__c: string; - CSB_Closeout_Request_Status__c: string; - Reimbursement_Needed__c: boolean; - attributes: { type: string; url: string }; - }; - attributes: { type: string; url: string }; -}; - -type BapFormSubmissions = { - 2022: { - frfs: BapFormSubmission[]; - prfs: BapFormSubmission[]; - crfs: BapFormSubmission[]; - }; - 2023: { - frfs: BapFormSubmission[]; - prfs: BapFormSubmission[]; - crfs: BapFormSubmission[]; - }; -}; - -export type FormType = "frf" | "prf" | "crf"; - -type FormioSubmission = { - [field: string]: unknown; - _id: string; // MongoDB ObjectId string – submission ID - form: string; // MongoDB ObjectId string – form ID - state: "submitted" | "draft"; - modified: string; // ISO 8601 date time string - metadata: { - [field: string]: unknown; - }; - data: { - [field: string]: unknown; - }; -}; - -type FormioChange2023Data = { - [field: string]: unknown; - // fields injected upon a new draft Change Request form submission creation: - _request_form: FormType; - _bap_entity_combo_key: string; - _bap_rebate_id: string; - _mongo_id: string; - _user_email: string; - _user_title: string; - _user_name: string; - // fields set by the form definition (among others): - request_type: { label: string; value: string }; -}; - -type FormioFRF2022Data = { - [field: string]: unknown; - // 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; -}; - -type FormioPRF2022Data = { - [field: string]: unknown; - // 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; - // fields set by form definition (among others): - applicantName: 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; - // fields set by form definition (among others): - signatureName: 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; - _formio_schoolDistrictName: string; -}; - -type FormioPRF2023Data = { - [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_rebate_id: string; - // TODO: add more here if helpful -}; - -type FormioCRF2023Data = { - [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_rebate_id: string; -}; - -export type FormioChange2023Submission = FormioSubmission & { - data: FormioChange2023Data; -}; - -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 FormioPRF2023Submission = FormioSubmission & { - data: FormioPRF2023Data; -}; - -export type FormioCRF2023Submission = FormioSubmission & { - data: FormioCRF2023Data; -}; - -export type BapSubmission = { - modified: string | null; // ISO 8601 date time string - comboKey: string | null; // UEI + EFTI combo key - mongoId: string | null; // MongoDB Object ID - rebateId: string | null; // CSB Rebate ID (6 digits) - reviewItemId: string | null; // CSB Rebate ID with form/version ID (9 digits) - status: string | null; - reimbursementNeeded: boolean; -}; +import { + type RebateYear, + type Content, + type UserData, + type ConfigData, + type BapSamEntity, + type BapSamData, + type BapFormSubmission, + type BapFormSubmissions, + type BapSubmissionData, + type FormioSubmission, + type FormioFRF2022Submission, + type FormioPRF2022Submission, + type FormioCRF2022Submission, + type FormioFRF2023Submission, + type FormioPRF2023Submission, + type FormioCRF2023Submission, + type FormioChange2023Submission, + type FormioFRF2024Submission, + type FormioPRF2024Submission, + type FormioCRF2024Submission, + type FormioChange2024Submission, + type Rebate2022, + type Rebate2023, + type Rebate2024, +} from "@/types"; +import { serverUrl, formioBapRebateIdField } from "@/config"; + +/** Formio Change Request submissions by rebate year. */ +/* prettier-ignore */ +type FormioChangeRequests = + Year extends "2022" ? never[] | undefined : + Year extends "2023" ? FormioChange2023Submission[] | undefined : + Year extends "2024" ? FormioChange2024Submission[] | undefined : + never; + +/** BAP and Formio submissions by rebate year. */ +/* prettier-ignore */ +type BapAndFormioSubmissions = + Year extends "2022" ? BapFormSubmissions | FormioFRF2022Submission[] | FormioPRF2022Submission[] | FormioCRF2022Submission[] : + Year extends "2023" ? BapFormSubmissions | FormioFRF2023Submission[] | FormioPRF2023Submission[] | FormioCRF2023Submission[] : + Year extends "2024" ? BapFormSubmissions | FormioFRF2024Submission[] | FormioPRF2024Submission[] | FormioCRF2024Submission[] : + never; -export type Rebate = { - rebateYear: RebateYear; - frf: { - formio: FormioFRF2022Submission | FormioFRF2023Submission; - bap: BapSubmission | null; - }; - prf: { - formio: FormioPRF2022Submission | FormioPRF2023Submission | null; - bap: BapSubmission | null; - }; - crf: { - formio: FormioCRF2022Submission | FormioCRF2023Submission | null; - bap: BapSubmission | null; - }; -}; +/** + * Formio and BAP submissions connected by rebate ID across all three forms + * (FRF, PRF, CRF) for a given rebate year. + */ +/* prettier-ignore */ +type Rebate = + Year extends "2022" ? Rebate2022 : + Year extends "2023" ? Rebate2023 : + Year extends "2024" ? Rebate2024 : + never; async function fetchData(url: string, options: RequestInit) { try { @@ -417,7 +182,9 @@ export function useBapSamData() { } /** Custom hook to fetch Change Request form submissions from Formio. */ -export function useChangeRequestsQuery(rebateYear: RebateYear) { +export function useChangeRequestsQuery( + rebateYear: Year, +): UseQueryResult> { /* * NOTE: Change Request form was added in the 2023 rebate year, so there's no * change request data to fetch for 2022. @@ -437,38 +204,63 @@ export function useChangeRequestsQuery(rebateYear: RebateYear) { refetchOnWindowFocus: false, }; + const changeRequest2024Query = { + queryKey: ["formio/2024/changes"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2024/changes`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + /* NOTE: Fallback (not used, as rebate year will match a query above) */ - const changeRequestQuery = { + const changeRequestFallbackQuery = { queryKey: ["formio/changes"], queryFn: () => Promise.resolve([]), refetchOnWindowFocus: false, }; - const query = + const query: UseQueryOptions> = rebateYear === "2022" ? changeRequest2022Query : rebateYear === "2023" ? changeRequest2023Query - : changeRequestQuery; + : rebateYear === "2024" + ? changeRequest2024Query + : changeRequestFallbackQuery; - return useQuery(query); + return useQuery(query) as UseQueryResult>; } /** * Custom hook that returns cached fetched Change Request form submissions from * Formio. */ -export function useChangeRequestsData(rebateYear: RebateYear) { +export function useChangeRequests( + rebateYear: Year, +): FormioChangeRequests { const queryClient = useQueryClient(); - return rebateYear === "2022" - ? queryClient.getQueryData<[]>(["formio/2022/changes"]) - : rebateYear === "2023" - ? queryClient.getQueryData(["formio/2023/changes"]) // prettier-ignore - : undefined; + + const changeRequest2022Data = queryClient.getQueryData<[]>(["formio/2022/changes"]); // prettier-ignore + const changeRequest2023Data = queryClient.getQueryData(["formio/2023/changes"]); // prettier-ignore + const changeRequest2024Data = queryClient.getQueryData(["formio/2024/changes"]); // prettier-ignore + + const result: FormioChangeRequests = + rebateYear === "2022" + ? changeRequest2022Data + : rebateYear === "2023" + ? changeRequest2023Data + : rebateYear === "2024" + ? changeRequest2024Data + : undefined; + + return result as FormioChangeRequests; } /** Custom hook to fetch submissions from the BAP and Formio. */ -export function useSubmissionsQueries(rebateYear: RebateYear) { +export function useSubmissionsQueries( + rebateYear: Year, +): UseQueryResult>[] { const bapQuery = { queryKey: ["bap/submissions"], queryFn: () => { @@ -478,7 +270,7 @@ export function useSubmissionsQueries(rebateYear: RebateYear) { return Promise.reject(res); } - const submissions = res.reduce( + const submissions: BapFormSubmissions = res.reduce( (object, submission) => { const { Record_Type_Name__c, Rebate_Program_Year__c } = submission; @@ -511,6 +303,11 @@ export function useSubmissionsQueries(rebateYear: RebateYear) { prfs: [] as BapFormSubmission[], crfs: [] as BapFormSubmission[], }, + 2024: { + frfs: [] as BapFormSubmission[], + prfs: [] as BapFormSubmission[], + crfs: [] as BapFormSubmission[], + }, }, ); @@ -574,27 +371,45 @@ export function useSubmissionsQueries(rebateYear: RebateYear) { refetchOnWindowFocus: false, }; - type Query = { - queryKey: string[]; - queryFn: () => - | Promise - | Promise - | Promise - | Promise - | Promise - | Promise - | Promise; - refetchOnWindowFocus: boolean; + const formioFRF2024Query = { + queryKey: ["formio/2024/frf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2024/frf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, }; - const queries: Query[] = + const formioPRF2024Query = { + queryKey: ["formio/2024/prf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2024/prf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + const formioCRF2024Query = { + queryKey: ["formio/2024/crf-submissions"], + queryFn: () => { + const url = `${serverUrl}/api/formio/2024/crf-submissions`; + return getData(url); + }, + refetchOnWindowFocus: false, + }; + + const queries: UseQueryOptions>[] = rebateYear === "2022" ? [bapQuery, formioFRF2022Query, formioPRF2022Query, formioCRF2022Query] : rebateYear === "2023" ? [bapQuery, formioFRF2023Query, formioPRF2023Query, formioCRF2023Query] - : []; + : rebateYear === "2024" + ? [bapQuery, formioFRF2024Query, formioPRF2024Query, formioCRF2024Query] // prettier-ignore + : []; - return useQueries({ queries }); + return useQueries({ queries }) as UseQueryResult< + BapAndFormioSubmissions + >[]; } /** @@ -602,34 +417,54 @@ export function useSubmissionsQueries(rebateYear: RebateYear) { * from both the BAP and Formio into a single object, with the BAP assigned * rebateId as the object's keys. **/ -function useCombinedSubmissions(rebateYear: RebateYear) { +function useCombinedSubmissions( + rebateYear: Year, +): { [rebateId: string]: Rebate } { const queryClient = useQueryClient(); const bapFormSubmissions = queryClient.getQueryData(["bap/submissions"]); // prettier-ignore + const formioFRF2022Data = queryClient.getQueryData(["formio/2022/frf-submissions"]); // prettier-ignore + const formioFRF2023Data = queryClient.getQueryData(["formio/2023/frf-submissions"]); // prettier-ignore + const formioFRF2024Data = queryClient.getQueryData(["formio/2024/frf-submissions"]); // prettier-ignore + + const formioPRF2022Data = queryClient.getQueryData(["formio/2022/prf-submissions"]); // prettier-ignore + const formioPRF2023Data = queryClient.getQueryData(["formio/2023/prf-submissions"]); // prettier-ignore + const formioPRF2024Data = queryClient.getQueryData(["formio/2024/prf-submissions"]); // prettier-ignore + + const formioCRF2022Data = queryClient.getQueryData(["formio/2022/crf-submissions"]); // prettier-ignore + const formioCRF2023Data = queryClient.getQueryData(["formio/2023/crf-submissions"]); // prettier-ignore + const formioCRF2024Data = queryClient.getQueryData(["formio/2024/crf-submissions"]); // prettier-ignore + const formioFRFSubmissions = rebateYear === "2022" - ? queryClient.getQueryData(["formio/2022/frf-submissions"]) // prettier-ignore + ? formioFRF2022Data : rebateYear === "2023" - ? queryClient.getQueryData(["formio/2023/frf-submissions"]) // prettier-ignore - : undefined; + ? formioFRF2023Data + : rebateYear === "2024" + ? formioFRF2024Data + : undefined; const formioPRFSubmissions = rebateYear === "2022" - ? queryClient.getQueryData(["formio/2022/prf-submissions"]) // prettier-ignore + ? formioPRF2022Data : rebateYear === "2023" - ? queryClient.getQueryData(["formio/2023/prf-submissions"]) // prettier-ignore - : undefined; + ? formioPRF2023Data + : rebateYear === "2024" + ? formioPRF2024Data + : undefined; const formioCRFSubmissions = rebateYear === "2022" - ? queryClient.getQueryData(["formio/2022/crf-submissions"]) // prettier-ignore + ? formioCRF2022Data : rebateYear === "2023" - ? queryClient.getQueryData(["formio/2023/crf-submissions"]) // prettier-ignore - : undefined; + ? formioCRF2023Data + : rebateYear === "2024" + ? formioCRF2024Data + : undefined; const submissions: { - [rebateId: string]: Rebate; + [rebateId: string]: Rebate; } = {}; /* ensure form submissions data has been fetched from both the BAP and Formio */ @@ -639,7 +474,7 @@ function useCombinedSubmissions(rebateYear: RebateYear) { !formioPRFSubmissions || !formioCRFSubmissions ) { - return {}; + return submissions; } /** @@ -685,7 +520,7 @@ function useCombinedSubmissions(rebateYear: RebateYear) { }, prf: { formio: null, bap: null }, crf: { formio: null, bap: null }, - }; + } as Rebate; } /** @@ -693,12 +528,9 @@ function useCombinedSubmissions(rebateYear: RebateYear) { * returned from the BAP, so we can set BAP PRF submission data. */ for (const formioPRFSubmission of formioPRFSubmissions) { + const formioBapPrfRebateIdField = formioBapRebateIdField[rebateYear].prf; const formioBapRebateId = - rebateYear === "2022" - ? (formioPRFSubmission as FormioPRF2022Submission).data.hidden_bap_rebate_id // prettier-ignore - : rebateYear === "2023" - ? (formioPRFSubmission as FormioPRF2023Submission).data._bap_rebate_id - : null; + (formioPRFSubmission.data?.[formioBapPrfRebateIdField] as string) || null; const bapMatch = bapFormSubmissions[rebateYear].prfs.find((bapPRFSub) => { return bapPRFSub.Parent_Rebate_ID__c === formioBapRebateId; @@ -724,7 +556,7 @@ function useCombinedSubmissions(rebateYear: RebateYear) { status, reimbursementNeeded, }, - }; + } as Rebate["prf"]; } } @@ -733,12 +565,9 @@ function useCombinedSubmissions(rebateYear: RebateYear) { * returned from the BAP, so we can set BAP CRF submission data. */ for (const formioCRFSubmission of formioCRFSubmissions) { + const formioBapCrfRebateIdField = formioBapRebateIdField[rebateYear].crf; const formioBapRebateId = - rebateYear === "2022" - ? (formioCRFSubmission as FormioCRF2022Submission).data.hidden_bap_rebate_id // prettier-ignore - : rebateYear === "2023" - ? (formioCRFSubmission as FormioCRF2023Submission).data._bap_rebate_id - : null; + (formioCRFSubmission.data?.[formioBapCrfRebateIdField] as string) || null; const bapMatch = bapFormSubmissions[rebateYear].crfs.find((bapCRFSub) => { return bapCRFSub.Parent_Rebate_ID__c === formioBapRebateId; @@ -764,7 +593,7 @@ function useCombinedSubmissions(rebateYear: RebateYear) { status, reimbursementNeeded, }, - }; + } as Rebate["crf"]; } } @@ -778,7 +607,9 @@ function useCombinedSubmissions(rebateYear: RebateYear) { * - Selected FRF submissions without a corresponding PRF submission * - Funding Approved PRF submissions without a corresponding CRF submission **/ -function useSortedSubmissions(rebates: { [rebateId: string]: Rebate }) { +function useSortedSubmissions(rebates: { + [rebateId: string]: Rebate; +}): (Rebate & { rebateId: string })[] { return Object.entries(rebates) .map(([rebateId, rebate]) => ({ rebateId, ...rebate })) .sort((r1, r2) => { @@ -839,7 +670,7 @@ function useSortedSubmissions(rebates: { [rebateId: string]: Rebate }) { * Custom hook that returns sorted submissions, and logs them if 'debug' search * parameter exists. */ -export function useSubmissions(rebateYear: RebateYear) { +export function useSubmissions(rebateYear: Year) { const [searchParams] = useSearchParams(); const combinedSubmissions = useCombinedSubmissions(rebateYear); @@ -864,7 +695,7 @@ export function useSubmissions(rebateYear: RebateYear) { */ export function submissionNeedsEdits(options: { formio: FormioSubmission | null; - bap: BapSubmission | null; + bap: BapSubmissionData | null; }) { const { formio, bap } = options; diff --git a/app/server/.env.example b/app/server/.env.example index 271ded31..088b05b6 100644 --- a/app/server/.env.example +++ b/app/server/.env.example @@ -16,6 +16,9 @@ CSB_2022_CRF_OPEN=true CSB_2023_FRF_OPEN=true CSB_2023_PRF_OPEN=true CSB_2023_CRF_OPEN=true +CSB_2024_FRF_OPEN=true +CSB_2024_PRF_OPEN=true +CSB_2024_CRF_OPEN=true FORMIO_2022_FRF_PATH= FORMIO_2022_PRF_PATH= FORMIO_2022_CRF_PATH= @@ -23,6 +26,10 @@ FORMIO_2023_FRF_PATH= FORMIO_2023_PRF_PATH= FORMIO_2023_CRF_PATH= FORMIO_2023_CHANGE_PATH= +FORMIO_2024_FRF_PATH= +FORMIO_2024_PRF_PATH= +FORMIO_2024_CRF_PATH= +FORMIO_2024_CHANGE_PATH= FORMIO_BASE_URL= FORMIO_PROJECT_NAME= FORMIO_API_KEY= diff --git a/app/server/app/config/formio.js b/app/server/app/config/formio.js index 63f44fd6..f3b3e00f 100644 --- a/app/server/app/config/formio.js +++ b/app/server/app/config/formio.js @@ -12,6 +12,9 @@ const { CSB_2023_FRF_OPEN, CSB_2023_PRF_OPEN, CSB_2023_CRF_OPEN, + CSB_2024_FRF_OPEN, + CSB_2024_PRF_OPEN, + CSB_2024_CRF_OPEN, FORMIO_BASE_URL, FORMIO_PROJECT_NAME, FORMIO_API_KEY, @@ -22,6 +25,10 @@ const { FORMIO_2023_PRF_PATH, FORMIO_2023_CRF_PATH, FORMIO_2023_CHANGE_PATH, + FORMIO_2024_FRF_PATH, + FORMIO_2024_PRF_PATH, + FORMIO_2024_CRF_PATH, + FORMIO_2024_CHANGE_PATH, } = process.env; const formioProjectUrl = `${FORMIO_BASE_URL}/${FORMIO_PROJECT_NAME}`; @@ -42,6 +49,12 @@ const formUrl = { crf: `${formioProjectUrl}/${FORMIO_2023_CRF_PATH}`, change: `${formioProjectUrl}/${FORMIO_2023_CHANGE_PATH}`, }, + 2024: { + frf: `${formioProjectUrl}/${FORMIO_2024_FRF_PATH}`, + prf: `${formioProjectUrl}/${FORMIO_2024_PRF_PATH}`, + crf: `${formioProjectUrl}/${FORMIO_2024_CRF_PATH}`, + change: `${formioProjectUrl}/${FORMIO_2024_CHANGE_PATH}`, + }, }; /** @@ -58,6 +71,11 @@ const submissionPeriodOpen = { prf: CSB_2023_PRF_OPEN === "true", crf: CSB_2023_CRF_OPEN === "true", }, + 2024: { + frf: CSB_2024_FRF_OPEN === "true", + prf: CSB_2024_PRF_OPEN === "true", + crf: CSB_2024_CRF_OPEN === "true", + }, }; /** @param {express.Request} req */ diff --git a/app/server/app/content/all-rebates-intro.md b/app/server/app/content/all-rebates-intro.md index 15462b12..d851bfa5 100644 --- a/app/server/app/content/all-rebates-intro.md +++ b/app/server/app/content/all-rebates-intro.md @@ -1,3 +1,6 @@ ## Your Rebate Forms Select a button below to _Edit_ or _View_ an existing rebate form. + +- For the 2023 rebate year, you may request edits, an extension, or a withdrawal by selecting Change Request, _Change_. +- For the 2022 rebate year, you may request edits, a withdrawal, or a Close Out Form extension (see [Close Out Form webpage](https://www.epa.gov/cleanschoolbus/clean-school-bus-rebates-close-out-form "external")) by emailing [cleanschoolbus@epa.gov](mailto:cleanschoolbus@epa.gov). diff --git a/app/server/app/index.js b/app/server/app/index.js index 34336562..97dfd791 100644 --- a/app/server/app/index.js +++ b/app/server/app/index.js @@ -35,6 +35,9 @@ const requiredEnvironmentVariables = [ "CSB_2023_FRF_OPEN", "CSB_2023_PRF_OPEN", "CSB_2023_CRF_OPEN", + "CSB_2024_FRF_OPEN", + "CSB_2024_PRF_OPEN", + "CSB_2024_CRF_OPEN", "FORMIO_2022_FRF_PATH", "FORMIO_2022_PRF_PATH", "FORMIO_2022_CRF_PATH", @@ -42,6 +45,10 @@ const requiredEnvironmentVariables = [ "FORMIO_2023_PRF_PATH", "FORMIO_2023_CRF_PATH", "FORMIO_2023_CHANGE_PATH", + "FORMIO_2024_FRF_PATH", + "FORMIO_2024_PRF_PATH", + "FORMIO_2024_CRF_PATH", + "FORMIO_2024_CHANGE_PATH", "FORMIO_BASE_URL", "FORMIO_PROJECT_NAME", "FORMIO_API_KEY", diff --git a/app/server/app/routes/formio2022.js b/app/server/app/routes/formio2022.js index b3428f7d..c079c2e9 100644 --- a/app/server/app/routes/formio2022.js +++ b/app/server/app/routes/formio2022.js @@ -1,23 +1,10 @@ const express = require("express"); -const ObjectId = require("mongodb").ObjectId; // --- -const { - axiosFormio, - formUrl, - submissionPeriodOpen, - formioCSBMetadata, - formioExampleRebateId, - formioNoUserAccess, -} = require("../config/formio"); const { ensureAuthenticated, storeBapComboKeys, verifyMongoObjectId, } = require("../middleware"); -const { - getBapDataFor2022CRF, - checkFormSubmissionPeriodAndBapStatus, -} = require("../utilities/bap"); const { uploadS3FileMetadata, downloadS3FileMetadata, @@ -34,10 +21,10 @@ const { deletePRFSubmission, // fetchCRFSubmissions, + createCRFSubmission, + fetchCRFSubmission, + updateCRFSubmission, } = require("../utilities/formio"); -const log = require("../utilities/logger"); - -const formioCRFUrl = formUrl["2022"].crf; const rebateYear = "2022"; const router = express.Router(); @@ -124,339 +111,17 @@ router.get("/crf-submissions", storeBapComboKeys, (req, res) => { // --- 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; - - // NOTE: included to support EPA API scan - if (Object.keys(body).length === 0) { - return res.json({}); - } - - 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 getBapDataFor2022CRF(req, frfReviewItemId, prfReviewItemId) - .then( - ({ frf2022RecordQuery, prf2022RecordQuery, prf2022busRecordsQuery }) => { - 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, - } = frf2022RecordQuery[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, - } = prf2022RecordQuery[0]; - - const busInfo = prf2022busRecordsQuery.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, // 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(`${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 }); - }); + createCRFSubmission({ rebateYear, req, res }); }); // --- 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) - - // NOTE: included to support EPA API scan - if (rebateId === formioExampleRebateId) { - return res.json(formioNoUserAccess); - } - - 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]) => { - if (submissions.length === 0) { - return res.json(formioNoUserAccess); - } - - 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(formioNoUserAccess); - } - - /** 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 }); - }); + fetchCRFSubmission({ rebateYear, req, res }); }); // --- 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; - - // NOTE: included to support EPA API scan - if (rebateId === formioExampleRebateId) { - return res.json({}); - } - - if (!mongoId || !submission) { - const errorStatus = 400; - const errorMessage = `Missing required data to update ${rebateYear} CRF submission '${rebateId}'.`; - return res.status(errorStatus).json({ message: errorMessage }); - } - - const comboKey = submission.data?.bap_hidden_entity_combo_key; - - checkFormSubmissionPeriodAndBapStatus({ - rebateYear, - 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 }); - }); + updateCRFSubmission({ rebateYear, req, res }); }); module.exports = router; diff --git a/app/server/app/routes/formio2023.js b/app/server/app/routes/formio2023.js index 77375317..d4aa959f 100644 --- a/app/server/app/routes/formio2023.js +++ b/app/server/app/routes/formio2023.js @@ -1,10 +1,5 @@ const express = require("express"); // --- -const { - formioExampleMongoId, - formioExampleRebateId, - formioNoUserAccess, -} = require("../config/formio"); const { ensureAuthenticated, storeBapComboKeys, @@ -25,6 +20,11 @@ const { updatePRFSubmission, deletePRFSubmission, // + // fetchCRFSubmissions, + // createCRFSubmission, + // fetchCRFSubmission, + // updateCRFSubmission, + // fetchChangeRequests, fetchChangeRequestSchema, createChangeRequest, @@ -111,15 +111,23 @@ router.post("/delete-prf-submission", storeBapComboKeys, (req, res) => { // --- get user's 2023 CRF submissions from Formio router.get("/crf-submissions", storeBapComboKeys, (req, res) => { - // TODO - res.json([]); + res.json([]); // TODO: replace with `fetchCRFSubmissions({ rebateYear, req, res })` when CRF is ready }); // --- post a new 2023 CRF submission to Formio +// router.post("/crf-submission", storeBapComboKeys, (req, res) => { +// createCRFSubmission({ rebateYear, req, res }); +// }); // --- get an existing 2023 CRF's schema and submission data from Formio +// router.get("/crf-submission/:rebateId", storeBapComboKeys, async (req, res) => { +// fetchCRFSubmission({ rebateYear, req, res }); +// }); // --- post an update to an existing draft 2023 CRF submission to Formio +// router.post("/crf-submission/:rebateId", storeBapComboKeys, (req, res) => { +// updateCRFSubmission({ rebateYear, req, res }); +// }); // --- get user's 2023 Change Request form submissions from Formio router.get("/changes", storeBapComboKeys, (req, res) => { diff --git a/app/server/app/routes/formio2024.js b/app/server/app/routes/formio2024.js new file mode 100644 index 00000000..2313769b --- /dev/null +++ b/app/server/app/routes/formio2024.js @@ -0,0 +1,152 @@ +const express = require("express"); +// --- +const { + ensureAuthenticated, + storeBapComboKeys, + verifyMongoObjectId, +} = require("../middleware"); +const { + uploadS3FileMetadata, + downloadS3FileMetadata, + // + fetchFRFSubmissions, + createFRFSubmission, + fetchFRFSubmission, + updateFRFSubmission, + // + // fetchPRFSubmissions, + // createPRFSubmission, + // fetchPRFSubmission, + // updatePRFSubmission, + // deletePRFSubmission, + // + // fetchCRFSubmissions, + // createCRFSubmission, + // fetchCRFSubmission, + // updateCRFSubmission, + // + fetchChangeRequests, + fetchChangeRequestSchema, + createChangeRequest, + fetchChangeRequest, +} = require("../utilities/formio"); + +const rebateYear = "2024"; +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, req, res }); + }, +); + +// --- upload Formio S3 file metadata +router.post( + "/s3/:formType/:mongoId/:comboKey/storage/s3", + storeBapComboKeys, + (req, res) => { + uploadS3FileMetadata({ rebateYear, req, res }); + }, +); + +// --- get user's 2024 FRF submissions from Formio +router.get("/frf-submissions", storeBapComboKeys, (req, res) => { + fetchFRFSubmissions({ rebateYear, req, res }); +}); + +// --- post a new 2024 FRF submission to Formio +router.post("/frf-submission", storeBapComboKeys, (req, res) => { + createFRFSubmission({ rebateYear, req, res }); +}); + +// --- get an existing 2024 FRF's schema and submission data from Formio +router.get( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + fetchFRFSubmission({ rebateYear, req, res }); + }, +); + +// --- post an update to an existing draft 2024 FRF submission to Formio +router.post( + "/frf-submission/:mongoId", + verifyMongoObjectId, + storeBapComboKeys, + (req, res) => { + updateFRFSubmission({ rebateYear, req, res }); + }, +); + +// --- get user's 2024 PRF submissions from Formio +router.get("/prf-submissions", storeBapComboKeys, (req, res) => { + res.json([]); // TODO: replace with `fetchPRFSubmissions({ rebateYear, req, res })` when PRF is ready +}); + +// --- post a new 2024 PRF submission to Formio +// router.post("/prf-submission", storeBapComboKeys, (req, res) => { +// createPRFSubmission({ rebateYear, req, res }); +// }); + +// --- get an existing 2024 PRF's schema and submission data from Formio +// router.get("/prf-submission/:rebateId", storeBapComboKeys, async (req, res) => { +// fetchPRFSubmission({ rebateYear, req, res }); +// }); + +// --- post an update to an existing draft 2024 PRF submission to Formio +// router.post("/prf-submission/:rebateId", storeBapComboKeys, (req, res) => { +// updatePRFSubmission({ rebateYear, req, res }); +// }); + +// --- delete an existing 2024 PRF submission from Formio +// router.post("/delete-prf-submission", storeBapComboKeys, (req, res) => { +// deletePRFSubmission({ rebateYear, req, res }); +// }); + +// --- get user's 2024 CRF submissions from Formio +router.get("/crf-submissions", storeBapComboKeys, (req, res) => { + res.json([]); // TODO: replace with `fetchCRFSubmissions({ rebateYear, req, res })` when CRF is ready +}); + +// --- post a new 2024 CRF submission to Formio +// router.post("/crf-submission", storeBapComboKeys, (req, res) => { +// createCRFSubmission({ rebateYear, req, res }); +// }); + +// --- get an existing 2024 CRF's schema and submission data from Formio +// router.get("/crf-submission/:rebateId", storeBapComboKeys, async (req, res) => { +// fetchCRFSubmission({ rebateYear, req, res }); +// }); + +// --- post an update to an existing draft 2024 CRF submission to Formio +// router.post("/crf-submission/:rebateId", storeBapComboKeys, (req, res) => { +// updateCRFSubmission({ rebateYear, req, res }); +// }); + +// --- get user's 2024 Change Request form submissions from Formio +router.get("/changes", storeBapComboKeys, (req, res) => { + fetchChangeRequests({ rebateYear, req, res }); +}); + +// --- get the 2024 Change Request form's schema from Formio +router.get("/change", storeBapComboKeys, (req, res) => { + fetchChangeRequestSchema({ rebateYear, req, res }); +}); + +// --- post a new 2024 Change Request form submission to Formio +router.post("/change", storeBapComboKeys, (req, res) => { + createChangeRequest({ rebateYear, req, res }); +}); + +// --- get an existing 2024 Change Request form's schema and submission data from Formio +router.get("/change/:mongoId", storeBapComboKeys, async (req, res) => { + fetchChangeRequest({ rebateYear, req, res }); +}); + +module.exports = router; diff --git a/app/server/app/routes/help.js b/app/server/app/routes/help.js index 43989ebf..816be34d 100644 --- a/app/server/app/routes/help.js +++ b/app/server/app/routes/help.js @@ -11,18 +11,32 @@ const { const { ensureAuthenticated, ensureHelpdesk } = require("../middleware"); const { getBapFormSubmissionData } = require("../utilities/bap"); +/** + * @typedef {'2022' | '2023' | '2024'} RebateYear + */ + +/** + * @typedef {'frf' | 'prf' | 'crf'} FormType + */ + const router = express.Router(); /** Confirm user is both authenticated and authorized with valid helpdesk roles. */ router.use(ensureAuthenticated); router.use(ensureHelpdesk); +/** @type {Map { (Record_Type_Name__c?.startsWith("CSB Funding Request") ? Parent_CSB_Rebate__r?.CSB_Funding_Request_Status__c : Record_Type_Name__c?.startsWith("CSB Payment Request") - ? Parent_CSB_Rebate__r?.CSB_Payment_Request_Status__c - : Record_Type_Name__c?.startsWith("CSB Close Out Request") - ? Parent_CSB_Rebate__r?.CSB_Closeout_Request_Status__c - : "") || null, + ? Parent_CSB_Rebate__r?.CSB_Payment_Request_Status__c + : Record_Type_Name__c?.startsWith("CSB Close Out Request") + ? Parent_CSB_Rebate__r?.CSB_Closeout_Request_Status__c + : "") || null, }, req, res, @@ -155,6 +161,44 @@ router.get("/formio/submission/:rebateYear/:formType/:id", (req, res) => { }); }); +// --- post an update to an existing form submission to Formio (change submission to 'draft') +router.post("/formio/submission/:rebateYear/:formType/:mongoId", (req, res) => { + const { body } = req; + const { rebateYear, formType, mongoId } = req.params; + + // NOTE: included to support EPA API scan + if (mongoId === formioExampleMongoId) { + return res.json({}); + } + + /** NOTE: verifyMongoObjectId */ + if (!ObjectId.isValid(mongoId)) { + const errorStatus = 400; + const errorMessage = `MongoDB ObjectId validation error for: '${mongoId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const formName = formioFormNameMap.get(formType) || "CSB"; + const formioFormUrl = formUrl[rebateYear][formType]; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} ${formName}.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + axiosFormio(req) + .put(`${formioFormUrl}/submission/${mongoId}`, 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 updating Formio ${rebateYear} ${formName} form submission '${mongoId}' to 'Draft'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +}); + // --- get all actions associated with a form's submission from Formio router.get("/formio/actions/:formId/:mongoId", (req, res) => { const { formId, mongoId } = req.params; diff --git a/app/server/app/routes/index.js b/app/server/app/routes/index.js index 9f0b3ddb..4da523a4 100644 --- a/app/server/app/routes/index.js +++ b/app/server/app/routes/index.js @@ -10,6 +10,7 @@ router.use("/api/bap", require("./bap")); router.use("/api/formio/nces", require("./formioNCES")); router.use("/api/formio/2022", require("./formio2022")); router.use("/api/formio/2023", require("./formio2023")); +router.use("/api/formio/2024", require("./formio2024")); router.use("/api/help", require("./help")); router.use("/api/status", require("./status")); diff --git a/app/server/app/routes/status.js b/app/server/app/routes/status.js index cc83d896..cc1e7004 100644 --- a/app/server/app/routes/status.js +++ b/app/server/app/routes/status.js @@ -26,7 +26,7 @@ router.get("/bap/sam", (req, res) => { return res.json({ status: true }); }) - .catch((error) => { + .catch((_error) => { // NOTE: logged in bap verifyBapConnection return res.json({ status: false }); }); @@ -39,7 +39,7 @@ router.get("/formio/2022/frf", (req, res) => { .then((schema) => { return res.json({ status: verifySchema(schema) }); }) - .catch((error) => { + .catch((_error) => { // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); @@ -52,7 +52,7 @@ router.get("/formio/2022/prf", (req, res) => { .then((schema) => { return res.json({ status: verifySchema(schema) }); }) - .catch((error) => { + .catch((_error) => { // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); @@ -65,7 +65,7 @@ router.get("/formio/2022/crf", (req, res) => { .then((schema) => { return res.json({ status: verifySchema(schema) }); }) - .catch((error) => { + .catch((_error) => { // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); @@ -78,7 +78,7 @@ router.get("/formio/2023/frf", (req, res) => { .then((schema) => { return res.json({ status: verifySchema(schema) }); }) - .catch((error) => { + .catch((_error) => { // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); @@ -91,7 +91,7 @@ router.get("/formio/2023/prf", (req, res) => { .then((schema) => { return res.json({ status: verifySchema(schema) }); }) - .catch((error) => { + .catch((_error) => { // NOTE: error is logged in axiosFormio response interceptor return res.json({ status: false }); }); @@ -104,7 +104,7 @@ router.get("/formio/2023/prf", (req, res) => { // .then((schema) => { // return res.json({ status: verifySchema(schema) }); // }) -// .catch((error) => { +// .catch((_error) => { // // NOTE: error is logged in axiosFormio response interceptor // return res.json({ status: false }); // }); diff --git a/app/server/app/utilities/bap.js b/app/server/app/utilities/bap.js index 43ff3c47..9f200e67 100644 --- a/app/server/app/utilities/bap.js +++ b/app/server/app/utilities/bap.js @@ -6,32 +6,42 @@ const log = require("../utilities/logger"); // --- const { submissionPeriodOpen } = require("../config/formio"); +/** + * @typedef {'2022' | '2023' | '2024'} RebateYear + */ + +/** + * @typedef {'frf' | 'prf' | 'crf'} FormType + */ + /** * @typedef {Object} BapSamEntity * @property {string} Id * @property {string} ENTITY_COMBO_KEY__c - * @property {string} ENTITY_STATUS__c * @property {string} UNIQUE_ENTITY_ID__c * @property {?string} ENTITY_EFT_INDICATOR__c + * @property {string} ENTITY_STATUS__c + * @property {?string} EXCLUSION_STATUS_FLAG__c + * @property {?string} DEBT_SUBJECT_TO_OFFSET_FLAG__c * @property {string} LEGAL_BUSINESS_NAME__c - * @property {?string} GOVT_BUS_POC_NAME__c - * @property {?string} GOVT_BUS_POC_EMAIL__c - * @property {?string} GOVT_BUS_POC_TITLE__c - * @property {?string} ALT_GOVT_BUS_POC_NAME__c - * @property {?string} ALT_GOVT_BUS_POC_EMAIL__c - * @property {?string} ALT_GOVT_BUS_POC_TITLE__c - * @property {?string} ELEC_BUS_POC_NAME__c - * @property {?string} ELEC_BUS_POC_EMAIL__c - * @property {?string} ELEC_BUS_POC_TITLE__c - * @property {?string} ALT_ELEC_BUS_POC_NAME__c - * @property {?string} ALT_ELEC_BUS_POC_EMAIL__c - * @property {?string} ALT_ELEC_BUS_POC_TITLE__c * @property {string} PHYSICAL_ADDRESS_LINE_1__c * @property {?string} PHYSICAL_ADDRESS_LINE_2__c * @property {string} PHYSICAL_ADDRESS_CITY__c * @property {string} PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c * @property {string} PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c * @property {string} PHYSICAL_ADDRESS_ZIP_CODE_4__c + * @property {?string} ELEC_BUS_POC_EMAIL__c + * @property {?string} ELEC_BUS_POC_NAME__c + * @property {?string} ELEC_BUS_POC_TITLE__c + * @property {?string} ALT_ELEC_BUS_POC_EMAIL__c + * @property {?string} ALT_ELEC_BUS_POC_NAME__c + * @property {?string} ALT_ELEC_BUS_POC_TITLE__c + * @property {?string} GOVT_BUS_POC_EMAIL__c + * @property {?string} GOVT_BUS_POC_NAME__c + * @property {?string} GOVT_BUS_POC_TITLE__c + * @property {?string} ALT_GOVT_BUS_POC_EMAIL__c + * @property {?string} ALT_GOVT_BUS_POC_NAME__c + * @property {?string} ALT_GOVT_BUS_POC_TITLE__c * @property {{ * type: string * url: string @@ -363,29 +373,30 @@ async function queryForSamEntities(req, email) { // `SELECT // Id, // ENTITY_COMBO_KEY__c, - // ENTITY_STATUS__c, // UNIQUE_ENTITY_ID__c, // ENTITY_EFT_INDICATOR__c, - // CAGE_CODE__c, + // ENTITY_STATUS__c, + // EXCLUSION_STATUS_FLAG__c, + // DEBT_SUBJECT_TO_OFFSET_FLAG__c, // LEGAL_BUSINESS_NAME__c, - // GOVT_BUS_POC_NAME__c, - // GOVT_BUS_POC_EMAIL__c, - // GOVT_BUS_POC_TITLE__c, - // ALT_GOVT_BUS_POC_NAME__c, - // ALT_GOVT_BUS_POC_EMAIL__c, - // ALT_GOVT_BUS_POC_TITLE__c, - // ELEC_BUS_POC_NAME__c, - // ELEC_BUS_POC_EMAIL__c, - // ELEC_BUS_POC_TITLE__c, - // ALT_ELEC_BUS_POC_NAME__c, - // ALT_ELEC_BUS_POC_EMAIL__c, - // ALT_ELEC_BUS_POC_TITLE__c, // PHYSICAL_ADDRESS_LINE_1__c, // PHYSICAL_ADDRESS_LINE_2__c, // PHYSICAL_ADDRESS_CITY__c, // PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c, // PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c, - // PHYSICAL_ADDRESS_ZIP_CODE_4__c + // PHYSICAL_ADDRESS_ZIP_CODE_4__c, + // ELEC_BUS_POC_EMAIL__c, + // ELEC_BUS_POC_NAME__c, + // ELEC_BUS_POC_TITLE__c, + // ALT_ELEC_BUS_POC_EMAIL__c, + // ALT_ELEC_BUS_POC_NAME__c, + // ALT_ELEC_BUS_POC_TITLE__c, + // GOVT_BUS_POC_EMAIL__c, + // GOVT_BUS_POC_NAME__c, + // GOVT_BUS_POC_TITLE__c, + // ALT_GOVT_BUS_POC_EMAIL__c, + // ALT_GOVT_BUS_POC_NAME__c, + // ALT_GOVT_BUS_POC_TITLE__c // FROM // Data_Staging__c // WHERE @@ -409,28 +420,30 @@ async function queryForSamEntities(req, email) { // "*": 1, Id: 1, ENTITY_COMBO_KEY__c: 1, - ENTITY_STATUS__c: 1, UNIQUE_ENTITY_ID__c: 1, ENTITY_EFT_INDICATOR__c: 1, + ENTITY_STATUS__c: 1, + EXCLUSION_STATUS_FLAG__c: 1, + DEBT_SUBJECT_TO_OFFSET_FLAG__c: 1, LEGAL_BUSINESS_NAME__c: 1, - GOVT_BUS_POC_NAME__c: 1, - GOVT_BUS_POC_EMAIL__c: 1, - GOVT_BUS_POC_TITLE__c: 1, - ALT_GOVT_BUS_POC_NAME__c: 1, - ALT_GOVT_BUS_POC_EMAIL__c: 1, - ALT_GOVT_BUS_POC_TITLE__c: 1, - ELEC_BUS_POC_NAME__c: 1, - ELEC_BUS_POC_EMAIL__c: 1, - ELEC_BUS_POC_TITLE__c: 1, - ALT_ELEC_BUS_POC_NAME__c: 1, - ALT_ELEC_BUS_POC_EMAIL__c: 1, - ALT_ELEC_BUS_POC_TITLE__c: 1, PHYSICAL_ADDRESS_LINE_1__c: 1, PHYSICAL_ADDRESS_LINE_2__c: 1, PHYSICAL_ADDRESS_CITY__c: 1, PHYSICAL_ADDRESS_PROVINCE_OR_STATE__c: 1, PHYSICAL_ADDRESS_ZIPPOSTAL_CODE__c: 1, PHYSICAL_ADDRESS_ZIP_CODE_4__c: 1, + ELEC_BUS_POC_EMAIL__c: 1, + ELEC_BUS_POC_NAME__c: 1, + ELEC_BUS_POC_TITLE__c: 1, + ALT_ELEC_BUS_POC_EMAIL__c: 1, + ALT_ELEC_BUS_POC_NAME__c: 1, + ALT_ELEC_BUS_POC_TITLE__c: 1, + GOVT_BUS_POC_EMAIL__c: 1, + GOVT_BUS_POC_NAME__c: 1, + GOVT_BUS_POC_TITLE__c: 1, + ALT_GOVT_BUS_POC_EMAIL__c: 1, + ALT_GOVT_BUS_POC_NAME__c: 1, + ALT_GOVT_BUS_POC_TITLE__c: 1, }, ) .execute(async (err, records) => ((await err) ? err : records)); @@ -441,8 +454,8 @@ async function queryForSamEntities(req, email) { * statuses and related metadata. * * @param {express.Request} req - * @param {'2022' | '2023'} rebateYear - * @param {'frf' | 'prf' | 'crf'} formType + * @param {RebateYear} rebateYear + * @param {FormType} formType * @param {string | null} rebateId * @param {string | null} mongoId * @returns {Promise} fields associated a form submission @@ -473,8 +486,13 @@ async function queryForBapFormSubmissionData( }, 2023: { frf: "CSB_Funding_Request_2023", - prf: null, // "CSB_Payment_Request_2023" - crf: null, // "CSB_Closeout_Request_2023" + prf: null, // TODO: "CSB_Payment_Request_2023" + crf: null, // TODO: "CSB_Closeout_Request_2023" + }, + 2024: { + frf: null, // TODO: "CSB_Funding_Request_2024" + prf: null, // TODO: "CSB_Payment_Request_2024" + crf: null, // TODO: "CSB_Closeout_Request_2024" }, }; @@ -544,7 +562,7 @@ async function queryForBapFormSubmissionData( CSB_Review_Item_ID__c: 1, // CSB Rebate ID with form/version ID (9 digits) Parent_Rebate_ID__c: 1, // CSB Rebate ID (6 digits) Record_Type_Name__c: 1, // 'CSB Funding Request' | 'CSB Payment Request' | 'CSB Close Out Request' | 'CSB Funding Request 2023' | 'CSB Payment Request 2023' | 'CSB Close Out Request 2023' - Rebate_Program_Year__c: 1, // '2022' | '2023' + Rebate_Program_Year__c: 1, // '2022' | '2023' | '2024' "Parent_CSB_Rebate__r.CSB_Funding_Request_Status__c": 1, "Parent_CSB_Rebate__r.CSB_Payment_Request_Status__c": 1, "Parent_CSB_Rebate__r.CSB_Closeout_Request_Status__c": 1, @@ -643,7 +661,7 @@ async function queryForBapFormSubmissionsStatuses(req) { CSB_Review_Item_ID__c: 1, // CSB Rebate ID with form/version ID (9 digits) Parent_Rebate_ID__c: 1, // CSB Rebate ID (6 digits) Record_Type_Name__c: 1, // 'CSB Funding Request' | 'CSB Payment Request' | 'CSB Close Out Request' | 'CSB Funding Request 2023' | 'CSB Payment Request 2023' | 'CSB Close Out Request 2023' - Rebate_Program_Year__c: 1, // '2022' | '2023' + Rebate_Program_Year__c: 1, // '2022' | '2023' | '2024' "Parent_CSB_Rebate__r.CSB_Funding_Request_Status__c": 1, "Parent_CSB_Rebate__r.CSB_Payment_Request_Status__c": 1, "Parent_CSB_Rebate__r.CSB_Closeout_Request_Status__c": 1, @@ -1481,8 +1499,8 @@ function getBapComboKeys(req, email) { * Fetches data associated with a provided form submission. * * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear - * @param {'frf' | 'prf' | 'crf'} param.formType + * @param {RebateYear} param.rebateYear + * @param {FormType} param.formType * @param {string | null} param.rebateId * @param {string | null} param.mongoId * @param {express.Request} param.req @@ -1578,8 +1596,8 @@ function checkForBapDuplicates(req) { * returned from the BAP). * * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear - * @param {'frf' | 'prf' | 'crf'} param.formType + * @param {RebateYear} param.rebateYear + * @param {FormType} param.formType * @param {string} param.mongoId * @param {string} param.comboKey * @param {express.Request} param.req diff --git a/app/server/app/utilities/formio.js b/app/server/app/utilities/formio.js index 54f6be3d..dc9d5ce8 100644 --- a/app/server/app/utilities/formio.js +++ b/app/server/app/utilities/formio.js @@ -15,34 +15,43 @@ const { getBapFormSubmissionsStatuses, getBapDataFor2022PRF, getBapDataFor2023PRF, + getBapDataFor2022CRF, checkFormSubmissionPeriodAndBapStatus, } = require("../utilities/bap"); const log = require("./logger"); const { NODE_ENV } = process.env; +/** + * @typedef {'2022' | '2023' | '2024'} RebateYear + */ + /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear */ function getComboKeyFieldName({ rebateYear }) { return rebateYear === "2022" ? "bap_hidden_entity_combo_key" : rebateYear === "2023" ? "_bap_entity_combo_key" - : ""; + : rebateYear === "2024" + ? "_bap_entity_combo_key" + : ""; } /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear */ function getRebateIdFieldName({ rebateYear }) { return rebateYear === "2022" ? "hidden_bap_rebate_id" : rebateYear === "2023" ? "_bap_rebate_id" - : ""; + : rebateYear === "2024" + ? "" // TODO + : ""; } /** @@ -75,7 +84,7 @@ function modifyDatasourceComponentsUrl(schema) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -88,7 +97,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { * comboKey: ?string * rebateId: ?string * frfReviewItemId: ?string - * frfFormModified: ?string + * frfModified: ?string * }} */ const { email, @@ -98,7 +107,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { comboKey, rebateId, frfReviewItemId, - frfFormModified, + frfModified, } = req.body; const { @@ -162,7 +171,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { return { data: { bap_hidden_entity_combo_key: comboKey, - hidden_application_form_modified: frfFormModified, + hidden_application_form_modified: frfModified, hidden_current_user_email: email, hidden_current_user_title: title, hidden_current_user_name: name, @@ -196,7 +205,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { state: "draft", }; }) - .catch((error) => { + .catch((_error) => { // NOTE: logged in bap verifyBapConnection const errorStatus = 500; const errorMessage = `Error getting data for a new 2022 Payment Request form submission from the BAP.`; @@ -259,7 +268,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { BillingState, BillingPostalCode, County__c, - } = Account; + } = Account || {}; const jsonOrg = frf2023RecordJson.data.organizations.find((org) => { const matchedName = org?.org_orgName?.trim() === orgName?.trim(); @@ -383,7 +392,7 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { return { data: { - _application_form_modified: frfFormModified, + _application_form_modified: frfModified, _bap_entity_combo_key: comboKey, _bap_rebate_id: rebateId, _user_email: email, @@ -447,18 +456,224 @@ function fetchDataForPRFSubmission({ rebateYear, req, res }) { state: "draft", }; }) - .catch((error) => { + .catch((_error) => { // NOTE: logged in bap verifyBapConnection const errorStatus = 500; const errorMessage = `Error getting data for a new 2023 Payment Request form submission from the BAP.`; return res.status(errorStatus).json({ message: errorMessage }); }); } + + if (rebateYear === "2024") { + // TODO + } +} + +/** + * @param {Object} param + * @param {RebateYear} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchDataForCRFSubmission({ rebateYear, req, res }) { + /** @type {{ + * email: string + * title: string + * name: string + * entity: import('./bap.js').BapSamEntity + * comboKey: ?string + * rebateId: ?string + * frfReviewItemId: ?string + * prfReviewItemId: ?string + * prfModified: ?string + * }} */ + const { + email, + title, + name, + entity, + comboKey, + rebateId, + frfReviewItemId, + prfReviewItemId, + prfModified, + } = req.body; + + 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; + + if (rebateYear === "2022") { + return getBapDataFor2022CRF(req, frfReviewItemId, prfReviewItemId) + .then((results) => { + const { + frf2022RecordQuery, + prf2022RecordQuery, + prf2022busRecordsQuery, + } = results; + + 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, + } = frf2022RecordQuery[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, + } = prf2022RecordQuery[0]; + + const busInfo = prf2022busRecordsQuery.map((prf2022BusRecord) => { + const { + Rebate_Item_num__c, + CSB_VIN__c, + CSB_Model_Year__c, + CSB_Fuel_Type__c, + Old_Bus_Estimated_Remaining_Life__c, + Old_Bus_Exclude__c, + Related_Line_Item__r, + New_Bus_Fuel_Type__c, + New_Bus_Make__c, + CSB_Manufacturer_if_Other__c, + New_Bus_Model__c, + New_Bus_Model_Year__c, + New_Bus_GVWR__c, + New_Bus_Purchase_Price__c, + New_Bus_Rebate_Amount__c, + } = prf2022BusRecord; + + return { + busNum: Rebate_Item_num__c, + oldBusNcesDistrictId: CSB_NCES_ID__c, + oldBusVin: CSB_VIN__c, + oldBusModelYear: CSB_Model_Year__c, + oldBusFuelType: CSB_Fuel_Type__c, + oldBusEstimatedRemainingLife: Old_Bus_Estimated_Remaining_Life__c, + oldBusExclude: Old_Bus_Exclude__c, + hidden_prf_oldBusExclude: Old_Bus_Exclude__c, + newBusDealer: Related_Line_Item__r?.Vendor_Name__c, + newBusFuelType: New_Bus_Fuel_Type__c, + hidden_prf_newBusFuelType: New_Bus_Fuel_Type__c, + newBusMake: New_Bus_Make__c, + hidden_prf_newBusMake: New_Bus_Make__c, + newBusMakeOther: CSB_Manufacturer_if_Other__c, + hidden_prf_newBusMakeOther: CSB_Manufacturer_if_Other__c, + newBusModel: New_Bus_Model__c, + hidden_prf_newBusModel: New_Bus_Model__c, + newBusModelYear: New_Bus_Model_Year__c, + hidden_prf_newBusModelYear: New_Bus_Model_Year__c, + newBusGvwr: New_Bus_GVWR__c, + hidden_prf_newBusGvwr: New_Bus_GVWR__c, + newBusPurchasePrice: New_Bus_Purchase_Price__c, + hidden_prf_newBusPurchasePrice: New_Bus_Purchase_Price__c, + hidden_prf_rebate: New_Bus_Rebate_Amount__c, + }; + }); + + return { + 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, // 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", + }; + }) + .catch((error) => { + // NOTE: logged in bap verifyBapConnection + const errorStatus = 500; + const errorMessage = `Error getting data for a new 2022 Close Out form submission from the BAP.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + } + + if (rebateYear === "2023") { + // TODO + } + + if (rebateYear === "2024") { + // TODO + } } /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -516,7 +731,7 @@ function uploadS3FileMetadata({ rebateYear, req, res }) { return res.status(errorStatus).json({ message: errorMessage }); }); }) - .catch((error) => { + .catch((_error) => { const formName = formType === "frf" ? "CSB Application" @@ -539,7 +754,7 @@ function uploadS3FileMetadata({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -586,7 +801,7 @@ function downloadS3FileMetadata({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -625,7 +840,7 @@ function fetchFRFSubmissions({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -683,7 +898,7 @@ function createFRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -728,9 +943,10 @@ function fetchFRFSubmission({ rebateYear, req, res }) { return res.json(formioNoUserAccess); } - /** Modify 2023 FRF's NCES API endpoint URL for local development */ + /** Modify 2023 and 2024 FRF's NCES API endpoint URL for local development */ const formSchemaJson = - NODE_ENV === "development" && rebateYear === "2023" + NODE_ENV === "development" && + (rebateYear === "2023" || rebateYear === "2024") ? modifyDatasourceComponentsUrl(schema) : schema; @@ -750,7 +966,7 @@ function fetchFRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -812,7 +1028,7 @@ function updateFRFSubmission({ rebateYear, req, res }) { return res.status(errorStatus).json({ message: errorMessage }); }); }) - .catch((error) => { + .catch((_error) => { const logMessage = `User with email '${mail}' attempted to update ${rebateYear} FRF ` + `submission '${mongoId}' when the CSB FRF enrollment period was closed.`; @@ -826,7 +1042,7 @@ function updateFRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -865,7 +1081,7 @@ function fetchPRFSubmissions({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -920,7 +1136,7 @@ function createPRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1009,7 +1225,7 @@ function fetchPRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1084,7 +1300,7 @@ function updatePRFSubmission({ rebateYear, req, res }) { return res.status(errorStatus).json({ message: errorMessage }); }); }) - .catch((error) => { + .catch((_error) => { const logMessage = `User with email '${mail}' attempted to update ${rebateYear} PRF ` + `submission '${rebateId}' when the CSB PRF enrollment period was closed.`; @@ -1098,7 +1314,7 @@ function updatePRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1171,7 +1387,7 @@ function deletePRFSubmission({ rebateYear, req, res }) { return res.status(errorStatus).json({ message: errorMessage }); }); }) - .catch((error) => { + .catch((_error) => { // NOTE: logged in bap verifyBapConnection const errorStatus = 500; const errorMessage = `Error getting form submissions statuses from the BAP.`; @@ -1181,7 +1397,7 @@ function deletePRFSubmission({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1220,7 +1436,240 @@ function fetchCRFSubmissions({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function createCRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { comboKey } = body; + + // NOTE: included to support EPA API scan + if (Object.keys(body).length === 0) { + return res.json({}); + } + + const formioFormUrl = formUrl[rebateYear].crf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} CRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + if (!submissionPeriodOpen[rebateYear].crf) { + const errorStatus = 400; + const errorMessage = `${rebateYear} 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 ${rebateYear} ` + + `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 }); + } + + fetchDataForCRFSubmission({ rebateYear, req, res }).then((submission) => { + axiosFormio(req) + .post(`${formioFormUrl}/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 ${rebateYear} Close Out form submission.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }); +} + +/** + * @param {Object} param + * @param {RebateYear} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function fetchCRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + + // NOTE: included to support EPA API scan + if (rebateId === formioExampleRebateId) { + return res.json(formioNoUserAccess); + } + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const rebateIdFieldName = getRebateIdFieldName({ rebateYear }); + + const formioFormUrl = formUrl[rebateYear].crf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} CRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const matchedCRFSubmissions = + `${formioFormUrl}/submission` + + `?data.${rebateIdFieldName}=${rebateId}` + + `&select=_id,data.${comboKeyFieldName}`; + + Promise.all([ + axiosFormio(req).get(matchedCRFSubmissions), + axiosFormio(req).get(formioFormUrl), + ]) + .then((axiosResponses) => axiosResponses.map((axiosRes) => axiosRes.data)) + .then(([submissions, schema]) => { + if (submissions.length === 0) { + return res.json(formioNoUserAccess); + } + + const submission = submissions[0]; + const mongoId = submission._id; + const comboKey = submission.data?.[comboKeyFieldName]; + + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to access ${rebateYear} ` + + `CRF submission '${mongoId}' that they do not have access to.`; + log({ level: "warn", message: logMessage, req }); + + return res.json(formioNoUserAccess); + } + + /** 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.${rebateIdFieldName}=${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(`${formioFormUrl}/submission/${mongoId}`) + .then((axiosRes) => axiosRes.data) + .then((submission) => { + 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} Close Out form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {RebateYear} param.rebateYear + * @param {express.Request} param.req + * @param {express.Response} param.res + */ +function updateCRFSubmission({ rebateYear, req, res }) { + const { bapComboKeys, body } = req; + const { mail } = req.user; + const { rebateId } = req.params; // CSB Rebate ID (6 digits) + const { mongoId, submission } = body; + + // NOTE: included to support EPA API scan + if (rebateId === formioExampleRebateId) { + return res.json({}); + } + + if (!mongoId || !submission) { + const errorStatus = 400; + const errorMessage = `Missing required data to update ${rebateYear} CRF submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + const comboKeyFieldName = getComboKeyFieldName({ rebateYear }); + const comboKey = submission.data?.[comboKeyFieldName]; + + const formioFormUrl = formUrl[rebateYear].crf; + + if (!formioFormUrl) { + const errorStatus = 400; + const errorMessage = `Formio form URL does not exist for ${rebateYear} CRF.`; + return res.status(errorStatus).json({ message: errorMessage }); + } + + checkFormSubmissionPeriodAndBapStatus({ + rebateYear, + formType: "crf", + mongoId, + comboKey, + req, + }) + .then(() => { + if (!bapComboKeys.includes(comboKey)) { + const logMessage = + `User with email '${mail}' attempted to update ${rebateYear} 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(`${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} Close Out form submission '${rebateId}'.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); + }) + .catch((error) => { + const logMessage = + `User with email '${mail}' attempted to update ${rebateYear} CRF ` + + `submission '${rebateId}' when the CSB CRF enrollment period was closed.`; + log({ level: "error", message: logMessage, req }); + + const errorStatus = 400; + const errorMessage = `${rebateYear} CSB Close Out form enrollment period is closed.`; + return res.status(errorStatus).json({ message: errorMessage }); + }); +} + +/** + * @param {Object} param + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1259,7 +1708,7 @@ function fetchChangeRequests({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1286,7 +1735,7 @@ function fetchChangeRequestSchema({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1338,7 +1787,7 @@ function createChangeRequest({ rebateYear, req, res }) { /** * @param {Object} param - * @param {'2022' | '2023'} param.rebateYear + * @param {RebateYear} param.rebateYear * @param {express.Request} param.req * @param {express.Response} param.res */ @@ -1413,6 +1862,9 @@ module.exports = { deletePRFSubmission, // fetchCRFSubmissions, + createCRFSubmission, + fetchCRFSubmission, + updateCRFSubmission, // fetchChangeRequests, fetchChangeRequestSchema,