From ad3a9c00b86616d7e14e11b7a64bf795f560e3df Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Tue, 4 Feb 2025 18:03:27 -0500 Subject: [PATCH 01/10] chore: work done so far --- .../form/[id]/rfi/[applicantRfiId].tsx | 243 +++++++++++++----- ...ormRfiAndCreateTemplateNineDataMutation.ts | 38 +++ ...ateRfiAndCreateTemplateNineDataMutation.ts | 33 +++ .../updateRfiAndFormDataMutation.ts | 31 +++ 4 files changed, 280 insertions(+), 65 deletions(-) create mode 100644 app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts create mode 100644 app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts create mode 100644 app/schema/mutations/application/updateRfiAndFormDataMutation.ts diff --git a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx index 0b95f02931..1e43ff7c0c 100644 --- a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx +++ b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx @@ -15,9 +15,11 @@ import { useRouter } from 'next/router'; import FormDiv from 'components/FormDiv'; import styled from 'styled-components'; import { useEffect, useState } from 'react'; -import { useCreateNewFormDataMutation } from 'schema/mutations/application/createNewFormData'; import useEmailNotification from 'lib/helpers/useEmailNotification'; import useRfiCoverageMapKmzUploadedEmail from 'lib/helpers/useRfiCoverageMapKmzUploadedEmail'; +import { useUpdateRfiAndCreateTemplateNineDataMutation } from 'schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation'; +import { useUpdateRfiAndFormDataMutation } from 'schema/mutations/application/updateRfiAndFormDataMutation'; +import { useUpdateFormRfiAndCreateTemplateNineDataMutation } from 'schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation'; const Flex = styled('header')` display: flex; @@ -64,14 +66,26 @@ const ApplicantRfiPage = ({ const { session, rfiDataByRowId, applicationByRowId } = query; const { rfiNumber } = rfiDataByRowId; const [updateRfi] = useUpdateWithTrackingRfiMutation(); + const [updateRfiAndCreateTemplateNineData] = + useUpdateRfiAndCreateTemplateNineDataMutation(); + const [updateFormRfiAndCreateTemplateNineData] = + useUpdateFormRfiAndCreateTemplateNineDataMutation(); + const [updateRfiAndFormData] = useUpdateRfiAndFormDataMutation(); const router = useRouter(); const formJsonData = applicationByRowId?.formData?.jsonData; const applicationId = router.query.id as string; const formSchemaId = applicationByRowId?.formData?.formSchemaId; const ccbcNumber = applicationByRowId?.ccbcNumber; const [newFormData, setNewFormData] = useState(formJsonData); - const [createNewFormData] = useCreateNewFormDataMutation(); + const [hasApplicationFormDataUpdated, setHasApplicationFormDataUpdated] = + useState(false); + const [templateNineData, setTemplateNineData] = useState(null); const [templateData, setTemplateData] = useState(null); + const [templatesUpdated, setTemplatesUpdated] = useState({ + one: false, + two: false, + nine: false, + }); const [formData, setFormData] = useState(rfiDataByRowId.jsonData); const { notifyHHCountUpdate } = useEmailNotification(); const { notifyRfiCoverageMapKmzUploaded } = @@ -89,6 +103,10 @@ const ApplicantRfiPage = ({ }, }; setNewFormData(newFormDataWithTemplateOne); + setTemplatesUpdated((prevTemplatesUpdated) => { + return { ...prevTemplatesUpdated, one: true }; + }); + setHasApplicationFormDataUpdated(true); } else if (templateData?.templateNumber === 2 && !templateData.error) { const newFormDataWithTemplateTwo = { ...newFormData, @@ -99,7 +117,21 @@ const ApplicantRfiPage = ({ }, }; setNewFormData(newFormDataWithTemplateTwo); - } else if (templateData?.error && templateData?.templateNumber === 1) { + setTemplatesUpdated((prevTemplatesUpdated) => { + return { ...prevTemplatesUpdated, two: true }; + }); + setHasApplicationFormDataUpdated(true); + } else if (templateData?.templateNumber === 9 && !templateData.error) { + setTemplatesUpdated((prevTemplatesUpdated) => { + return { ...prevTemplatesUpdated, nine: true }; + }); + setTemplateNineData({ ...templateData }); + } else if ( + templateData?.error && + (templateData?.templateNumber === 1 || + templateData?.templateNumber === 2 || + templateData?.templateNumber === 9) + ) { const fileArrayLength = newFormData.templateUploads?.eligibilityAndImpactsCalculator?.length; fetch(`/api/email/notifyFailedReadOfTemplateData`, { @@ -119,89 +151,170 @@ const ApplicantRfiPage = ({ }, }), }); - } else if (templateData?.error && templateData?.templateNumber === 2) { - const fileArrayLength = - newFormData.templateUploads?.detailedBudget?.length; - fetch(`/api/email/notifyFailedReadOfTemplateData`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - applicationId, - host: window.location.origin, - params: { - templateNumber: templateData.templateNumber, - uuid: newFormData.templateUploads?.detailedBudget?.[ - fileArrayLength - 1 - ]?.uuid, - uploadedAt: - newFormData.templateUploads?.detailedBudget?.[fileArrayLength - 1] - ?.uploadedAt, - }, - }), - }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [templateData]); const handleSubmit = (e: IChangeEvent) => { - updateRfi({ - variables: { - input: { - jsonData: e.formData, - rfiRowId: rfiDataByRowId.rowId, - }, - }, - onCompleted: () => { - if (e.formData?.rfiAdditionalFiles?.geographicCoverageMap?.length > 0) { - notifyRfiCoverageMapKmzUploaded( - rfiDataByRowId, - e.formData, - applicationId, + const getTemplateNineUUID = () => { + // can be wrong source if there are multiple uploads + return e.formData?.rfiAdditionalFiles?.geographicNames?.[0]?.uuid; + }; + + const checkAndNotifyRfiCoverage = async () => { + if (e.formData?.rfiAdditionalFiles?.geographicCoverageMap?.length > 0) { + return notifyRfiCoverageMapKmzUploaded( + rfiDataByRowId, + e.formData, + applicationId, + ccbcNumber, + rfiNumber, + applicationByRowId.organizationName + ); + } + return Promise.resolve(); + }; + + const checkAndNotifyHHCount = async () => { + if (templatesUpdated?.one) { + return notifyHHCountUpdate( + newFormData.benefits, + formJsonData.benefits, + applicationId, + { ccbcNumber, + timestamp: new Date().toLocaleString(), + manualUpdate: false, rfiNumber, - applicationByRowId.organizationName - ); - } - if (!templateData) { - router.push(`/applicantportal/dashboard`); - } - }, - onError: (err) => { - // eslint-disable-next-line no-console - console.log('Error updating RFI', err); - }, - }); - if (templateData) { - createNewFormData({ + organizationName: applicationByRowId.organizationName, + } + ); + } + return Promise.resolve(); + }; + + if (!hasApplicationFormDataUpdated && !templatesUpdated.nine) { + // form data not updated and template nine not updated + // only update rfi + updateRfi({ variables: { input: { + jsonData: e.formData, + rfiRowId: rfiDataByRowId.rowId, + }, + }, + onCompleted: () => { + checkAndNotifyRfiCoverage().then(() => { + // wait until email is sent before redirecting + router.push(`/applicantportal/dashboard`); + }); + }, + onError: (err) => { + // eslint-disable-next-line no-console + console.log('Error updating RFI', err); + }, + }); + } else if (!hasApplicationFormDataUpdated && templatesUpdated.nine) { + // form data not updated but template nine updated, update rfi and create template nine record + updateRfiAndCreateTemplateNineData({ + variables: { + rfiInput: { + jsonData: e.formData, + rfiRowId: rfiDataByRowId.rowId, + }, + templateNineInput: { + applicationFormTemplate9Data: { + applicationId: Number(applicationId), + jsonData: templateNineData.data, + source: { + source: 'RFI', + uuid: getTemplateNineUUID(), + }, + }, + }, + }, + onError: (err) => { + // eslint-disable-next-line no-console + console.log( + 'Error updating RFI and creating template nine data', + err + ); + }, + onCompleted: () => { + checkAndNotifyRfiCoverage().then(() => { + // wait until email(s) is sent before redirecting + router.push(`/applicantportal/dashboard`); + }); + }, + }); + } else if (hasApplicationFormDataUpdated && !templatesUpdated.nine) { + // only update rfi and form data since no template nine data + updateRfiAndFormData({ + variables: { + formInput: { applicationRowId: Number(applicationId), jsonData: newFormData, reasonForChange: `Auto updated from upload for RFI: ${rfiNumber}`, formSchemaId, }, + rfiInput: { + jsonData: e.formData, + rfiRowId: rfiDataByRowId.rowId, + }, }, onError: (err) => { // eslint-disable-next-line no-console console.log('Error creating new form data', err); }, onCompleted: () => { - if (templateData?.templateNumber === 1) { - notifyHHCountUpdate( - newFormData.benefits, - formJsonData.benefits, - applicationId, - { - ccbcNumber, - timestamp: new Date().toLocaleString(), - manualUpdate: false, - rfiNumber, - organizationName: applicationByRowId.organizationName, - } - ); - } setTemplateData(null); - router.push(`/applicantportal/dashboard`); + checkAndNotifyRfiCoverage().then(() => { + checkAndNotifyHHCount().then(() => { + // wait until email is sent before redirecting + router.push(`/applicantportal/dashboard`); + }); + }); + }, + }); + } else if (hasApplicationFormDataUpdated && templatesUpdated.nine) { + // update rfi, form data, and template nine data (all three) + updateFormRfiAndCreateTemplateNineData({ + variables: { + formInput: { + applicationRowId: Number(applicationId), + jsonData: newFormData, + reasonForChange: `Auto updated from upload for RFI: ${rfiNumber}`, + formSchemaId, + }, + rfiInput: { + jsonData: e.formData, + rfiRowId: rfiDataByRowId.rowId, + }, + templateNineInput: { + applicationFormTemplate9Data: { + applicationId: Number(applicationId), + jsonData: templateNineData.data, + source: { + source: 'RFI', + uuid: getTemplateNineUUID(), + }, + }, + }, + }, + onError: (err) => { + // eslint-disable-next-line no-console + console.log( + 'Error updating RFI, form data, and template nine data', + err + ); + }, + onCompleted: () => { + checkAndNotifyHHCount().then(() => { + checkAndNotifyRfiCoverage().then(() => { + // wait until email(s) is sent before redirecting + router.push(`/applicantportal/dashboard`); + }); + }); }, }); } diff --git a/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts b/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts new file mode 100644 index 0000000000..a676e2ae84 --- /dev/null +++ b/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts @@ -0,0 +1,38 @@ +import { graphql } from 'react-relay'; +import { updateFormRfiAndCreateTemplateNineDataMutation } from '__generated__/updateFormRfiAndCreateTemplateNineDataMutation.graphql'; +import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; + +const mutation = graphql` + mutation updateFormRfiAndCreateTemplateNineDataMutation( + $rfiInput: UpdateRfiInput! + $templateNineInput: CreateApplicationFormTemplate9DataInput! + $formInput: CreateNewFormDataInput! + ) { + updateRfi(input: $rfiInput) { + rfiData { + rfiNumber + rowId + id + } + } + createApplicationFormTemplate9Data(input: $templateNineInput) { + applicationFormTemplate9Data { + rowId + applicationId + } + } + createNewFormData(input: $formInput) { + formData { + jsonData + } + } + } +`; + +const useUpdateFormRfiAndCreateTemplateNineDataMutation = () => + useMutationWithErrorMessage( + mutation, + () => 'An error occurred while attempting to create the application.' + ); + +export { mutation, useUpdateFormRfiAndCreateTemplateNineDataMutation }; diff --git a/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts b/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts new file mode 100644 index 0000000000..d4f121c758 --- /dev/null +++ b/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts @@ -0,0 +1,33 @@ +import { graphql } from 'react-relay'; +import { updateRfiAndCreateTemplateNineDataMutation } from '__generated__/updateRfiAndCreateTemplateNineDataMutation.graphql'; +import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; + +const mutation = graphql` + mutation updateRfiAndCreateTemplateNineDataMutation( + $rfiInput: UpdateRfiInput! + $templateNineInput: CreateApplicationFormTemplate9DataInput! + ) { + updateRfi(input: $rfiInput) { + rfiData { + rfiNumber + rowId + id + } + } + createApplicationFormTemplate9Data(input: $templateNineInput) { + applicationFormTemplate9Data { + rowId + applicationId + } + } + } +`; + +const useUpdateRfiAndCreateTemplateNineDataMutation = () => + useMutationWithErrorMessage( + mutation, + () => + 'An error occurred while attempting to update the RFI with new Template Nine Data.' + ); + +export { mutation, useUpdateRfiAndCreateTemplateNineDataMutation }; diff --git a/app/schema/mutations/application/updateRfiAndFormDataMutation.ts b/app/schema/mutations/application/updateRfiAndFormDataMutation.ts new file mode 100644 index 0000000000..9c001468f8 --- /dev/null +++ b/app/schema/mutations/application/updateRfiAndFormDataMutation.ts @@ -0,0 +1,31 @@ +import { graphql } from 'react-relay'; +import { updateRfiAndFormDataMutation } from '__generated__/updateRfiAndFormDataMutation.graphql'; +import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; + +const mutation = graphql` + mutation updateRfiAndFormDataMutation( + $rfiInput: UpdateRfiInput! + $formInput: CreateNewFormDataInput! + ) { + updateRfi(input: $rfiInput) { + rfiData { + rfiNumber + rowId + id + } + } + createNewFormData(input: $formInput) { + formData { + jsonData + } + } + } +`; + +const useUpdateRfiAndFormDataMutation = () => + useMutationWithErrorMessage( + mutation, + () => 'An error occurred while attempting to create the application.' + ); + +export { mutation, useUpdateRfiAndFormDataMutation }; From 5cfeffe91676589c8d853b1ad873c40bcf521259 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Wed, 5 Feb 2025 12:51:06 -0500 Subject: [PATCH 02/10] chore: add backend functionality --- app/backend/lib/excel_import/template_nine.ts | 57 ++++++++++++++++- app/lib/theme/widgets/FileWidget.tsx | 61 +++++++++++++------ .../form/[id]/rfi/[applicantRfiId].tsx | 6 +- ...data_002_add_auth_user_role_permission.sql | 16 +++++ ...data_002_add_auth_user_role_permission.sql | 9 +++ db/sqitch.plan | 1 + 6 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 db/deploy/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql create mode 100644 db/revert/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql diff --git a/app/backend/lib/excel_import/template_nine.ts b/app/backend/lib/excel_import/template_nine.ts index ff414db343..4eef307e59 100644 --- a/app/backend/lib/excel_import/template_nine.ts +++ b/app/backend/lib/excel_import/template_nine.ts @@ -509,6 +509,60 @@ templateNine.get( } ); +templateNine.post( + '/api/template-nine/rfi/applicant/:id/:rfiNumber', + limiter, + async (req, res) => { + const authRole = getAuthRole(req); + const pgRole = authRole?.pgRole; + const isRoleAuthorized = pgRole === 'ccbc_auth_user'; + if (!isRoleAuthorized) { + return res.status(404).end(); + } + + const { id, rfiNumber } = req.params; + + const applicationId = parseInt(id, 10); + + if (!id || !rfiNumber || Number.isNaN(applicationId)) { + return res.status(400).json({ error: 'Invalid parameters' }); + } + const errorList = []; + const form = formidable(commonFormidableConfig); + + let files; + try { + files = await parseForm(form, req); + } catch (err) { + errorList.push({ level: 'file', error: err }); + return res.status(400).json(errorList).end(); + } + const filename = Object.keys(files)[0]; + const uploadedFilesArray = files[filename] as Array; + const uploaded = uploadedFilesArray?.[0]; + + if (!uploaded) { + return res.status(400).end(); + } + const buf = fs.readFileSync(uploaded.filepath); + const wb = XLSX.read(buf); + let templateNineData; + try { + templateNineData = await loadTemplateNineData(wb); + } catch (err) { + errorList.push({ level: 'file', error: err }); + return res.status(400).json(errorList).end(); + } + + if (templateNineData) { + return res.status(200).json({ templateNineData }); + } + return res + .status(400) + .json({ error: 'Unknown error while parsing template nine' }); + } +); + templateNine.post( '/api/template-nine/rfi/:id/:rfiNumber', limiter, @@ -518,7 +572,8 @@ templateNine.post( const isRoleAuthorized = pgRole === 'ccbc_admin' || pgRole === 'super_admin' || - pgRole === 'ccbc_analyst'; + pgRole === 'ccbc_analyst' || + pgRole === 'ccbc_auth_user'; if (!isRoleAuthorized) { return res.status(404).end(); diff --git a/app/lib/theme/widgets/FileWidget.tsx b/app/lib/theme/widgets/FileWidget.tsx index dd4e74d945..8ceff71325 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -72,6 +72,8 @@ const FileWidget: React.FC = ({ const { setTemplateData, rfiNumber } = formContext; const { showToast, hideToast } = useToast(); + const isApplicantPage = router.pathname.includes('applicant'); + useEffect(() => { if (rawErrors?.length > 0) { setErrors([{ error: 'rjsf_validation' }]); @@ -109,25 +111,50 @@ const FileWidget: React.FC = ({ }); } } else if (templateNumber === 9) { - const response = await fetch( - `/api/template-nine/rfi/${formId}/${rfiNumber}`, - { - method: 'POST', - body: fileFormData, + if (isApplicantPage) { + // fetch for applicant and handle as expected + const response = await fetch( + `/api/template-nine/rfi/applicant/${formId}/${rfiNumber}`, + { + method: 'POST', + body: fileFormData, + } + ); + if (response.ok) { + const data = await response.json(); + setTemplateData({ + templateNumber, + data: data.templateNineData, + templateName: file.name, + }); + } else { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); } - ); - if (response.ok) { - await response.json(); - setTemplateData({ - templateNumber, - templateName: file.name, - }); } else { - isTemplateValid = false; - setTemplateData({ - templateNumber, - error: true, - }); + const response = await fetch( + `/api/template-nine/rfi/${formId}/${rfiNumber}`, + { + method: 'POST', + body: fileFormData, + } + ); + if (response.ok) { + await response.json(); + setTemplateData({ + templateNumber, + templateName: file.name, + }); + } else { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); + } } } } catch (error) { diff --git a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx index 1e43ff7c0c..c29939dc30 100644 --- a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx +++ b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx @@ -349,7 +349,11 @@ const ApplicantRfiPage = ({ onChange={handleChange} onSubmit={handleSubmit} noValidate - formContext={{ setTemplateData, skipUnsavedWarning: true }} + formContext={{ + setTemplateData, + skipUnsavedWarning: true, + rfiNumber, + }} > diff --git a/db/deploy/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql b/db/deploy/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql new file mode 100644 index 0000000000..ba419edbbc --- /dev/null +++ b/db/deploy/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql @@ -0,0 +1,16 @@ +-- Deploy ccbc:tables/application_form_template_9_data_002_add_auth_user_role_permission to pg + +begin; + +do +$grant$ +begin + +perform ccbc_private.grant_permissions('select', 'application_form_template_9_data', 'ccbc_auth_user'); +perform ccbc_private.grant_permissions('insert', 'application_form_template_9_data', 'ccbc_auth_user'); +perform ccbc_private.grant_permissions('update', 'application_form_template_9_data', 'ccbc_auth_user'); + +end +$grant$; + +commit; diff --git a/db/revert/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql b/db/revert/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql new file mode 100644 index 0000000000..f2d74eeb07 --- /dev/null +++ b/db/revert/tables/application_form_template_9_data_002_add_auth_user_role_permission.sql @@ -0,0 +1,9 @@ +-- Revert ccbc:tables/application_form_template_9_data_002_add_auth_user_role_permission from pg + +begin; + +revoke select on ccbc_public.application_form_template_9_data from ccbc_auth_user; +revoke insert on ccbc_public.application_form_template_9_data from ccbc_auth_user; +revoke update on ccbc_public.application_form_template_9_data from ccbc_auth_user; + +commit; diff --git a/db/sqitch.plan b/db/sqitch.plan index 88e42c2d94..15d48b9bfb 100644 --- a/db/sqitch.plan +++ b/db/sqitch.plan @@ -815,3 +815,4 @@ computed_columns/cbc_history [computed_columns/cbc_history@1.230.0] 2025-02-19T2 tables/application_fnha_contribution 2025-02-24T19:40:28Z ,,, # Add fnha contribution table mutations/save_fnha_contribution 2025-02-25T16:48:17Z ,,, # Add mutation to save fnha contribution @1.246.0 2025-03-05T22:03:48Z CCBC Service Account # release v1.246.0 +tables/application_form_template_9_data_002_add_auth_user_role_permission 2025-02-05T15:11:34Z Anthony Bushara # Adds permissions for an applicant to insert and read template 9 data From 868a8d3bc85d1f832d176a7225fa2082e4aa157b Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Wed, 5 Feb 2025 15:41:05 -0500 Subject: [PATCH 03/10] chore: fix error result and change how template data is returned --- app/backend/lib/excel_import/template_nine.ts | 2 +- app/lib/theme/widgets/FileWidget.tsx | 2 +- .../form/[id]/rfi/[applicantRfiId].tsx | 38 ++++++++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/backend/lib/excel_import/template_nine.ts b/app/backend/lib/excel_import/template_nine.ts index 4eef307e59..32bbec5e19 100644 --- a/app/backend/lib/excel_import/template_nine.ts +++ b/app/backend/lib/excel_import/template_nine.ts @@ -555,7 +555,7 @@ templateNine.post( } if (templateNineData) { - return res.status(200).json({ templateNineData }); + return res.status(200).json(templateNineData); } return res .status(400) diff --git a/app/lib/theme/widgets/FileWidget.tsx b/app/lib/theme/widgets/FileWidget.tsx index 8ceff71325..612457f75e 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -124,7 +124,7 @@ const FileWidget: React.FC = ({ const data = await response.json(); setTemplateData({ templateNumber, - data: data.templateNineData, + data, templateName: file.name, }); } else { diff --git a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx index c29939dc30..665c66c4c8 100644 --- a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx +++ b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx @@ -92,6 +92,23 @@ const ApplicantRfiPage = ({ useRfiCoverageMapKmzUploadedEmail(); useEffect(() => { + const getFileDetails = (templateNumber) => { + if (templateNumber === 1) { + return formData?.rfiAdditionalFiles + ?.eligibilityAndImpactsCalculator?.[0]; + } + if (templateNumber === 2) { + return formData?.rfiAdditionalFiles?.detailedBudget?.[0]; + } + if (templateNumber === 9) { + return formData?.rfiAdditionalFiles?.geographicNames?.[0]; + } + + return null; + }; + + const fileDetails = getFileDetails(templateData?.templateNumber); + if (templateData?.templateNumber === 1 && !templateData.error) { const newFormDataWithTemplateOne = { ...newFormData, @@ -106,6 +123,7 @@ const ApplicantRfiPage = ({ setTemplatesUpdated((prevTemplatesUpdated) => { return { ...prevTemplatesUpdated, one: true }; }); + setTemplateData(null); setHasApplicationFormDataUpdated(true); } else if (templateData?.templateNumber === 2 && !templateData.error) { const newFormDataWithTemplateTwo = { @@ -121,19 +139,20 @@ const ApplicantRfiPage = ({ return { ...prevTemplatesUpdated, two: true }; }); setHasApplicationFormDataUpdated(true); + setTemplateData(null); } else if (templateData?.templateNumber === 9 && !templateData.error) { setTemplatesUpdated((prevTemplatesUpdated) => { return { ...prevTemplatesUpdated, nine: true }; }); setTemplateNineData({ ...templateData }); + setTemplateData(null); } else if ( + fileDetails && templateData?.error && (templateData?.templateNumber === 1 || templateData?.templateNumber === 2 || templateData?.templateNumber === 9) ) { - const fileArrayLength = - newFormData.templateUploads?.eligibilityAndImpactsCalculator?.length; fetch(`/api/email/notifyFailedReadOfTemplateData`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -142,18 +161,16 @@ const ApplicantRfiPage = ({ host: window.location.origin, params: { templateNumber: templateData.templateNumber, - uuid: newFormData.templateUploads - ?.eligibilityAndImpactsCalculator?.[fileArrayLength - 1]?.uuid, - uploadedAt: - newFormData.templateUploads?.eligibilityAndImpactsCalculator?.[ - fileArrayLength - 1 - ]?.uploadedAt, + uuid: fileDetails.uuid, + uploadedAt: fileDetails?.uploadedAt, }, }), + }).then(() => { + setTemplateData(null); }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [templateData]); + }, [templateData, formData]); const handleSubmit = (e: IChangeEvent) => { const getTemplateNineUUID = () => { @@ -204,6 +221,7 @@ const ApplicantRfiPage = ({ }, }, onCompleted: () => { + setTemplateData(null); checkAndNotifyRfiCoverage().then(() => { // wait until email is sent before redirecting router.push(`/applicantportal/dashboard`); @@ -241,6 +259,7 @@ const ApplicantRfiPage = ({ ); }, onCompleted: () => { + setTemplateData(null); checkAndNotifyRfiCoverage().then(() => { // wait until email(s) is sent before redirecting router.push(`/applicantportal/dashboard`); @@ -309,6 +328,7 @@ const ApplicantRfiPage = ({ ); }, onCompleted: () => { + setTemplateData(null); checkAndNotifyHHCount().then(() => { checkAndNotifyRfiCoverage().then(() => { // wait until email(s) is sent before redirecting From 7ac61c9e507b52ad61577d1a2431b4fd21e7050b Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Wed, 5 Feb 2025 16:44:52 -0500 Subject: [PATCH 04/10] chore: update tests --- .../form/[id]/rfi/[applicantRfiId].test.tsx | 125 ++++++++++-------- 1 file changed, 72 insertions(+), 53 deletions(-) diff --git a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx index 7a358b00c7..6c2d31c550 100644 --- a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx +++ b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx @@ -43,6 +43,10 @@ const mockQueryPayload = { rfi: { updatedAt: '2022-12-01', }, + formData: { + jsonData: {}, + formSchemaId: 'test', + }, projectName: 'projName', status: 'Received', }, @@ -217,59 +221,71 @@ describe('The applicantRfiId Page', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); }); - pageTestingHelper.expectMutationToBeCalled( - 'updateWithTrackingRfiMutation', - { - input: { - jsonData: { - rfiType: [], - rfiAdditionalFiles: { - eligibilityAndImpactsCalculatorRfi: true, - detailedBudgetRfi: true, - eligibilityAndImpactsCalculator: [ - { - id: 1, - uuid: 'string', - name: 'file.xlsx', - size: 1, - type: 'application/vnd.ms-excel', - uploadedAt: expect.anything(), - }, - ], - detailedBudget: [ - { - id: 2, - uuid: 'string', - name: 'file.xlsx', - size: 1, - type: 'application/vnd.ms-excel', - uploadedAt: expect.anything(), - }, - ], - geographicCoverageMapRfi: true, - geographicCoverageMap: [ - { - uuid: 1, - name: '1.kmz', - size: 0, - type: '', - uploadedAt: '2024-05-31T14:05:03.509-07:00', - }, - { - uuid: 2, - name: '2.kmz', - size: 0, - type: '', - uploadedAt: '2024-05-31T14:05:03.509-07:00', - }, - ], - }, - rfiDueBy: '2022-12-22', + pageTestingHelper.expectMutationToBeCalled('updateRfiAndFormDataMutation', { + rfiInput: { + jsonData: { + rfiType: [], + rfiAdditionalFiles: { + detailedBudgetRfi: true, + eligibilityAndImpactsCalculatorRfi: true, + geographicCoverageMapRfi: true, + geographicCoverageMap: [ + { + uuid: 1, + name: '1.kmz', + size: 0, + type: '', + uploadedAt: expect.anything(), + }, + { + uuid: 2, + name: '2.kmz', + size: 0, + type: '', + uploadedAt: expect.anything(), + }, + ], + eligibilityAndImpactsCalculator: [ + { + id: 1, + uuid: 'string', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], + detailedBudget: [ + { + id: 2, + uuid: 'string', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], }, - rfiRowId: 1, + rfiDueBy: '2022-12-22', }, - } - ); + rfiRowId: 1, + }, + formInput: { + applicationRowId: 1, + jsonData: { + benefits: { + householdsImpactedIndigenous: 60, + numberOfHouseholds: 4, + }, + budgetDetails: { + totalEligibleCosts: 92455, + totalProjectCost: 101230, + }, + }, + reasonForChange: 'Auto updated from upload for RFI: CCBC-01001-01', + formSchemaId: 'test', + }, + }); }); it('uses template 1 and 2 data to notify if failed template read', async () => { pageTestingHelper.loadQuery(); @@ -308,7 +324,7 @@ describe('The applicantRfiId Page', () => { fireEvent.change(inputFile, { target: { files: [file] } }); }); - act(() => { + await act(async () => { pageTestingHelper.environment.mock.resolveMostRecentOperation({ data: { createAttachment: { @@ -328,10 +344,13 @@ describe('The applicantRfiId Page', () => { '/api/applicant/template?templateNumber=1', { body: formData, method: 'POST' } ); + expect(global.fetch).toHaveBeenCalledWith( '/api/email/notifyFailedReadOfTemplateData', { - body: '{"applicationId":"1","host":"http://localhost","params":{"templateNumber":1}}', + body: expect.stringContaining( + '{"applicationId":"1","host":"http://localhost","params":{"templateNumber":1,"uuid":"string","uploadedAt":' + ), headers: { 'Content-Type': 'application/json' }, method: 'POST', } From 4c9f6f933a633ac508fbf70aef7ddc64aa49ddd6 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Thu, 6 Feb 2025 11:19:01 -0500 Subject: [PATCH 05/10] test: update testing for applicantRfiId --- .../form/[id]/rfi/[applicantRfiId].test.tsx | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx index 6c2d31c550..17ceb8031a 100644 --- a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx +++ b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx @@ -12,6 +12,7 @@ const mockQueryPayload = { jsonData: { rfiDueBy: '2022-12-22', rfiAdditionalFiles: { + geographicNamesRfi: true, detailedBudgetRfi: true, eligibilityAndImpactsCalculatorRfi: true, geographicCoverageMapRfi: true, @@ -69,6 +70,7 @@ describe('The applicantRfiId Page', () => { pageTestingHelper.reinit(); pageTestingHelper.setMockRouterValues({ query: { applicantRfiId: '1', id: '1' }, + pathname: '/applicantportal/form/1/rfi/1', }); }); @@ -101,6 +103,7 @@ describe('The applicantRfiId Page', () => { jsonData: { rfiType: [], rfiAdditionalFiles: { + geographicNamesRfi: true, eligibilityAndImpactsCalculatorRfi: true, detailedBudgetRfi: true, geographicCoverageMapRfi: true, @@ -226,6 +229,7 @@ describe('The applicantRfiId Page', () => { jsonData: { rfiType: [], rfiAdditionalFiles: { + geographicNamesRfi: true, detailedBudgetRfi: true, eligibilityAndImpactsCalculatorRfi: true, geographicCoverageMapRfi: true, @@ -391,6 +395,7 @@ describe('The applicantRfiId Page', () => { jsonData: { rfiType: [], rfiAdditionalFiles: { + geographicNamesRfi: true, eligibilityAndImpactsCalculatorRfi: true, detailedBudgetRfi: true, eligibilityAndImpactsCalculator: [ @@ -438,4 +443,326 @@ describe('The applicantRfiId Page', () => { } ); }); + it('uses template 9 data and creates new record', async () => { + // load page test + // mock fetch for template 9 + // upload fake template 9 + // click save + // expect update rfi and create new record mutation to be called + + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + const mockSuccessResponseTemplateNine = { + errors: [], + projectZone: 'zone', + geoName: 'geoName', + }; + + const mockFetchPromiseTemplateNine = Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ...mockSuccessResponseTemplateNine }), + }); + + global.fetch = jest.fn((url) => { + if (url.includes('template-nine')) return mockFetchPromiseTemplateNine; + return Promise.resolve({ ok: true, status: 200 }); + }); + + const file = new File([new ArrayBuffer(1)], 'file.xlsx', { + type: 'application/vnd.ms-excel', + }); + // grab the third file input since that is the one for template 9 + const inputFile = screen.getAllByTestId('file-test')[2]; + + await act(async () => { + fireEvent.change(inputFile, { target: { files: [file] } }); + }); + + await act(async () => { + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 3, + file: 'UUIDstring', + }, + }, + }, + }); + }); + + const formData = new FormData(); + formData.append('file', file); + + expect(global.fetch).toHaveBeenCalledOnce(); + expect(global.fetch).toHaveBeenCalledWith( + '/api/template-nine/rfi/applicant/1/CCBC-01001-01', + { + body: formData, + method: 'POST', + } + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateRfiAndCreateTemplateNineDataMutation', + { + rfiInput: { + jsonData: { + rfiType: [], + rfiAdditionalFiles: { + geographicNamesRfi: true, + detailedBudgetRfi: true, + eligibilityAndImpactsCalculatorRfi: true, + geographicCoverageMapRfi: true, + geographicCoverageMap: [ + { + uuid: 1, + name: '1.kmz', + size: 0, + type: '', + uploadedAt: '2024-05-31T14:05:03.509-07:00', + }, + { + uuid: 2, + name: '2.kmz', + size: 0, + type: '', + uploadedAt: '2024-05-31T14:05:03.509-07:00', + }, + ], + geographicNames: [ + { + id: 3, + uuid: 'UUIDstring', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], + }, + rfiDueBy: '2022-12-22', + }, + rfiRowId: 1, + }, + templateNineInput: { + applicationFormTemplate9Data: { + applicationId: 1, + jsonData: mockSuccessResponseTemplateNine, + source: { + source: 'RFI', + uuid: 'UUIDstring', + }, + }, + }, + } + ); + }); + + it('uses template 1, 2, and 9 data to update form, rfi data, and create new record', async () => { + // load page test + // mock fetch for template 1, 2, and 9 + // upload fake template 1, 2, and 9 + // click save + // expect update rfi, form, and create new record mutation to be called + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + const mockSuccessResponseTemplateOne = { + totalNumberHouseholdsImpacted: 60, + finalEligibleHouseholds: 4, + }; + const mockFetchPromiseTemplateOne = Promise.resolve({ + ok: false, + status: 200, + json: () => Promise.resolve({ result: mockSuccessResponseTemplateOne }), + }); + + const mockSuccessResponseTemplateTwo = { + totalEligibleCosts: 92455, + totalProjectCosts: 101230, + }; + const mockFetchPromiseTemplateTwo = Promise.resolve({ + ok: false, + status: 200, + json: () => Promise.resolve({ result: mockSuccessResponseTemplateTwo }), + }); + + const mockSuccessResponseTemplateNine = { + errors: [], + projectZone: 'zone', + geoName: 'geoName', + }; + + const mockFetchPromiseTemplateNine = Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ ...mockSuccessResponseTemplateNine }), + }); + + global.fetch = jest.fn((url) => { + if (url.includes('templateNumber=1')) return mockFetchPromiseTemplateOne; + if (url.includes('templateNumber=2')) return mockFetchPromiseTemplateTwo; + return mockFetchPromiseTemplateNine; + }); + + const file = new File([new ArrayBuffer(1)], 'file.xlsx', { + type: 'application/vnd.ms-excel', + }); + + const templateOneInputFile = screen.getAllByTestId('file-test')[0]; + const templateTwoInputFile = screen.getAllByTestId('file-test')[1]; + const templateNineInputFile = screen.getAllByTestId('file-test')[2]; + // upload template 1 + await act(async () => { + fireEvent.change(templateOneInputFile, { target: { files: [file] } }); + }); + // resolve template 1 upload + await act(async () => { + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 1, + file: 'UUIDTemplateOne', + }, + }, + }, + }); + }); + // upload template 2 + await act(async () => { + fireEvent.change(templateTwoInputFile, { target: { files: [file] } }); + }); + // resolve template 2 upload + await act(async () => { + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 2, + file: 'UUIDTemplateTwo', + }, + }, + }, + }); + }); + // upload template 9 + await act(async () => { + fireEvent.change(templateNineInputFile, { target: { files: [file] } }); + }); + // resolve template 9 upload + await act(async () => { + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + createAttachment: { + attachment: { + rowId: 3, + file: 'UUIDTemplateNine', + }, + }, + }, + }); + }); + + expect(global.fetch).toHaveBeenCalledTimes(5); + expect(global.fetch).toHaveBeenCalledWith( + '/api/applicant/template?templateNumber=1', + { body: expect.any(FormData), method: 'POST' } + ); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/applicant/template?templateNumber=2', + { body: expect.any(FormData), method: 'POST' } + ); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/template-nine/rfi/applicant/1/CCBC-01001-01', + { body: expect.any(FormData), method: 'POST' } + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateRfiAndCreateTemplateNineDataMutation', + { + rfiInput: { + jsonData: { + rfiType: [], + rfiAdditionalFiles: { + geographicNamesRfi: true, + detailedBudgetRfi: true, + eligibilityAndImpactsCalculatorRfi: true, + geographicCoverageMapRfi: true, + geographicCoverageMap: [ + { + uuid: 1, + name: '1.kmz', + size: 0, + type: '', + uploadedAt: '2024-05-31T14:05:03.509-07:00', + }, + { + uuid: 2, + name: '2.kmz', + size: 0, + type: '', + uploadedAt: '2024-05-31T14:05:03.509-07:00', + }, + ], + eligibilityAndImpactsCalculator: [ + { + id: 1, + uuid: 'UUIDTemplateOne', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], + detailedBudget: [ + { + id: 2, + uuid: 'UUIDTemplateTwo', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], + geographicNames: [ + { + id: 3, + uuid: 'UUIDTemplateNine', + name: 'file.xlsx', + size: 1, + type: 'application/vnd.ms-excel', + uploadedAt: expect.anything(), + }, + ], + }, + rfiDueBy: '2022-12-22', + }, + rfiRowId: 1, + }, + templateNineInput: { + applicationFormTemplate9Data: { + applicationId: 1, + jsonData: mockSuccessResponseTemplateNine, + source: { + source: 'RFI', + uuid: 'UUIDTemplateNine', + }, + }, + }, + } + ); + }); }); From 9ce6b684b763537694e7b17dc89f09479acbec3e Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Thu, 6 Feb 2025 16:02:39 -0500 Subject: [PATCH 06/10] chore: refactor to have update and create logic in backend --- .../form/[id]/rfi/[applicantRfiId].tsx | 38 +++++++---- ...ormRfiAndCreateTemplateNineDataMutation.ts | 4 +- ...ateRfiAndCreateTemplateNineDataMutation.ts | 4 +- app/schema/schema.graphql | 67 +++++++++++++++++++ .../form/[id]/rfi/[applicantRfiId].test.tsx | 39 +++++++---- ...pdate_application_form_template_9_data.sql | 41 ++++++++++++ ...pdate_application_form_template_9_data.sql | 7 ++ db/sqitch.plan | 1 + 8 files changed, 169 insertions(+), 32 deletions(-) create mode 100644 db/deploy/mutations/create_or_update_application_form_template_9_data.sql create mode 100644 db/revert/mutations/create_or_update_application_form_template_9_data.sql diff --git a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx index 665c66c4c8..73eaa90409 100644 --- a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx +++ b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx @@ -44,6 +44,13 @@ const getApplicantRfiIdQuery = graphql` id ccbcNumber organizationName + applicationFormTemplate9DataByApplicationId( + filter: { archivedAt: { isNull: true } } + ) { + nodes { + rowId + } + } formData { id formSchemaId @@ -76,6 +83,9 @@ const ApplicantRfiPage = ({ const applicationId = router.query.id as string; const formSchemaId = applicationByRowId?.formData?.formSchemaId; const ccbcNumber = applicationByRowId?.ccbcNumber; + const applicationFormTemplate9DataId = + applicationByRowId?.applicationFormTemplate9DataByApplicationId?.nodes?.[0] + ?.rowId; const [newFormData, setNewFormData] = useState(formJsonData); const [hasApplicationFormDataUpdated, setHasApplicationFormDataUpdated] = useState(false); @@ -241,14 +251,14 @@ const ApplicantRfiPage = ({ rfiRowId: rfiDataByRowId.rowId, }, templateNineInput: { - applicationFormTemplate9Data: { - applicationId: Number(applicationId), - jsonData: templateNineData.data, - source: { - source: 'RFI', - uuid: getTemplateNineUUID(), - }, + _applicationId: Number(applicationId), + _jsonData: templateNineData.data, + _previousTemplate9Id: applicationFormTemplate9DataId, + _source: { + source: 'RFI', + uuid: getTemplateNineUUID(), }, + _errors: templateNineData.data?.errors, }, }, onError: (err) => { @@ -310,14 +320,14 @@ const ApplicantRfiPage = ({ rfiRowId: rfiDataByRowId.rowId, }, templateNineInput: { - applicationFormTemplate9Data: { - applicationId: Number(applicationId), - jsonData: templateNineData.data, - source: { - source: 'RFI', - uuid: getTemplateNineUUID(), - }, + _applicationId: Number(applicationId), + _jsonData: templateNineData.data, + _source: { + source: 'RFI', + uuid: getTemplateNineUUID(), }, + _previousTemplate9Id: applicationFormTemplate9DataId, + _errors: templateNineData.data?.errors, }, }, onError: (err) => { diff --git a/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts b/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts index a676e2ae84..cc091edc26 100644 --- a/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts +++ b/app/schema/mutations/application/updateFormRfiAndCreateTemplateNineDataMutation.ts @@ -5,7 +5,7 @@ import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; const mutation = graphql` mutation updateFormRfiAndCreateTemplateNineDataMutation( $rfiInput: UpdateRfiInput! - $templateNineInput: CreateApplicationFormTemplate9DataInput! + $templateNineInput: CreateOrUpdateApplicationFormTemplate9DataInput! $formInput: CreateNewFormDataInput! ) { updateRfi(input: $rfiInput) { @@ -15,7 +15,7 @@ const mutation = graphql` id } } - createApplicationFormTemplate9Data(input: $templateNineInput) { + createOrUpdateApplicationFormTemplate9Data(input: $templateNineInput) { applicationFormTemplate9Data { rowId applicationId diff --git a/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts b/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts index d4f121c758..4e1922ff40 100644 --- a/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts +++ b/app/schema/mutations/application/updateRfiAndCreateTemplateNineDataMutation.ts @@ -5,7 +5,7 @@ import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; const mutation = graphql` mutation updateRfiAndCreateTemplateNineDataMutation( $rfiInput: UpdateRfiInput! - $templateNineInput: CreateApplicationFormTemplate9DataInput! + $templateNineInput: CreateOrUpdateApplicationFormTemplate9DataInput! ) { updateRfi(input: $rfiInput) { rfiData { @@ -14,7 +14,7 @@ const mutation = graphql` id } } - createApplicationFormTemplate9Data(input: $templateNineInput) { + createOrUpdateApplicationFormTemplate9Data(input: $templateNineInput) { applicationFormTemplate9Data { rowId applicationId diff --git a/app/schema/schema.graphql b/app/schema/schema.graphql index 9c01552ab6..b1fc006508 100644 --- a/app/schema/schema.graphql +++ b/app/schema/schema.graphql @@ -91071,6 +91071,12 @@ type Mutation { """ input: CreateNewFormDataInput! ): CreateNewFormDataPayload + createOrUpdateApplicationFormTemplate9Data( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateOrUpdateApplicationFormTemplate9DataInput! + ): CreateOrUpdateApplicationFormTemplate9DataPayload createPackage( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -105486,6 +105492,67 @@ input CreateNewFormDataInput { formSchemaId: Int! } +""" +The output of our `createOrUpdateApplicationFormTemplate9Data` mutation. +""" +type CreateOrUpdateApplicationFormTemplate9DataPayload { + """ + The exact same `clientMutationId` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String + applicationFormTemplate9Data: ApplicationFormTemplate9Data + + """ + Our root query field type. Allows us to run any query from our mutation payload. + """ + query: Query + + """ + Reads a single `Application` that is related to this `ApplicationFormTemplate9Data`. + """ + applicationByApplicationId: Application + + """ + Reads a single `CcbcUser` that is related to this `ApplicationFormTemplate9Data`. + """ + ccbcUserByCreatedBy: CcbcUser + + """ + Reads a single `CcbcUser` that is related to this `ApplicationFormTemplate9Data`. + """ + ccbcUserByUpdatedBy: CcbcUser + + """ + Reads a single `CcbcUser` that is related to this `ApplicationFormTemplate9Data`. + """ + ccbcUserByArchivedBy: CcbcUser + + """ + An edge for our `ApplicationFormTemplate9Data`. May be used by Relay 1. + """ + applicationFormTemplate9DataEdge( + """The method to use when ordering `ApplicationFormTemplate9Data`.""" + orderBy: [ApplicationFormTemplate9DataOrderBy!] = [PRIMARY_KEY_ASC] + ): ApplicationFormTemplate9DataEdge +} + +""" +All input for the `createOrUpdateApplicationFormTemplate9Data` mutation. +""" +input CreateOrUpdateApplicationFormTemplate9DataInput { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String + _applicationId: Int! + _jsonData: JSON! + _errors: JSON! + _source: JSON! + _previousTemplate9Id: Int +} + """The output of our `createPackage` mutation.""" type CreatePackagePayload { """ diff --git a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx index 17ceb8031a..5a16d0bce1 100644 --- a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx +++ b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx @@ -48,6 +48,13 @@ const mockQueryPayload = { jsonData: {}, formSchemaId: 'test', }, + applicationFormTemplate9DataByApplicationId: { + nodes: [ + { + rowId: 1, + }, + ], + }, projectName: 'projName', status: 'Received', }, @@ -552,14 +559,18 @@ describe('The applicantRfiId Page', () => { rfiRowId: 1, }, templateNineInput: { - applicationFormTemplate9Data: { - applicationId: 1, - jsonData: mockSuccessResponseTemplateNine, - source: { - source: 'RFI', - uuid: 'UUIDstring', - }, + _applicationId: 1, + _jsonData: { + errors: [], + projectZone: 'zone', + geoName: 'geoName', + }, + _previousTemplate9Id: 1, + _source: { + source: 'RFI', + uuid: 'UUIDstring', }, + _errors: [], }, } ); @@ -753,14 +764,14 @@ describe('The applicantRfiId Page', () => { rfiRowId: 1, }, templateNineInput: { - applicationFormTemplate9Data: { - applicationId: 1, - jsonData: mockSuccessResponseTemplateNine, - source: { - source: 'RFI', - uuid: 'UUIDTemplateNine', - }, + _previousTemplate9Id: 1, + _applicationId: 1, + _jsonData: mockSuccessResponseTemplateNine, + _source: { + source: 'RFI', + uuid: 'UUIDTemplateNine', }, + _errors: [], }, } ); diff --git a/db/deploy/mutations/create_or_update_application_form_template_9_data.sql b/db/deploy/mutations/create_or_update_application_form_template_9_data.sql new file mode 100644 index 0000000000..675accdbe5 --- /dev/null +++ b/db/deploy/mutations/create_or_update_application_form_template_9_data.sql @@ -0,0 +1,41 @@ +-- Deploy ccbc:mutations/create_or_update_application_form_template_9_data to pg + +begin; + +create or replace function ccbc_public.create_or_update_application_form_template_9_data( + _application_id int, + _json_data jsonb, + _errors jsonb, + _source jsonb, + _previous_template_9_id int default null + ) + returns ccbc_public.application_form_template_9_data as + $$ + declare + result ccbc_public.application_form_template_9_data; + begin + -- if previous template 9 id is provided, update the existing record + -- else create a new record + + -- return the updated or created record + + if _previous_template_9_id is not null then + update ccbc_public.application_form_template_9_data + set + application_id = _application_id, + json_data = _json_data, + errors = _errors, + source = _source + where id = _previous_template_9_id + returning * into result; + else + insert into ccbc_public.application_form_template_9_data(application_id, json_data, errors, source) + values (_application_id, _json_data, _errors, _source) + returning * into result; + end if; + + return result; + end; + $$ language plpgsql volatile; + +commit; diff --git a/db/revert/mutations/create_or_update_application_form_template_9_data.sql b/db/revert/mutations/create_or_update_application_form_template_9_data.sql new file mode 100644 index 0000000000..c6dc4ec82e --- /dev/null +++ b/db/revert/mutations/create_or_update_application_form_template_9_data.sql @@ -0,0 +1,7 @@ +-- Revert ccbc:mutations/create_or_update_application_form_template_9_data from pg + +BEGIN; + +drop function ccbc_public.create_or_update_application_form_template_9_data(int, jsonb, jsonb, jsonb, int); + +COMMIT; diff --git a/db/sqitch.plan b/db/sqitch.plan index 15d48b9bfb..2e0173da97 100644 --- a/db/sqitch.plan +++ b/db/sqitch.plan @@ -816,3 +816,4 @@ tables/application_fnha_contribution 2025-02-24T19:40:28Z ,,, # Add mutation to save fnha contribution @1.246.0 2025-03-05T22:03:48Z CCBC Service Account # release v1.246.0 tables/application_form_template_9_data_002_add_auth_user_role_permission 2025-02-05T15:11:34Z Anthony Bushara # Adds permissions for an applicant to insert and read template 9 data +mutations/create_or_update_application_form_template_9_data 2025-02-06T16:46:05Z Anthony Bushara # Move the decision making of updating or creating template 9 data from frontend to the backend to create cleaner code From 41e692170f406cb60000422cbaaa4fa03463d968 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Thu, 6 Feb 2025 16:07:20 -0500 Subject: [PATCH 07/10] chore: cleanup and tests for applicant template nine --- app/backend/lib/excel_import/template_nine.ts | 3 +-- .../lib/excel_import/template_nine.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/backend/lib/excel_import/template_nine.ts b/app/backend/lib/excel_import/template_nine.ts index 32bbec5e19..2974cbd504 100644 --- a/app/backend/lib/excel_import/template_nine.ts +++ b/app/backend/lib/excel_import/template_nine.ts @@ -572,8 +572,7 @@ templateNine.post( const isRoleAuthorized = pgRole === 'ccbc_admin' || pgRole === 'super_admin' || - pgRole === 'ccbc_analyst' || - pgRole === 'ccbc_auth_user'; + pgRole === 'ccbc_analyst'; if (!isRoleAuthorized) { return res.status(404).end(); diff --git a/app/tests/backend/lib/excel_import/template_nine.test.ts b/app/tests/backend/lib/excel_import/template_nine.test.ts index 7ce1694ae6..a831a136ef 100644 --- a/app/tests/backend/lib/excel_import/template_nine.test.ts +++ b/app/tests/backend/lib/excel_import/template_nine.test.ts @@ -324,4 +324,22 @@ describe('The Community Progress Report import', () => { expect(response.status).toBe(200); }); + + it('should process the rfi for applicant', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_auth_user', + landingRoute: '/', + }; + }); + + const response = await request(app) + .post('/api/template-nine/rfi/applicant/1/CCBC-00001-1') + .set('Content-Type', 'application/json') + .set('Connection', 'keep-alive') + .field('data', JSON.stringify({ name: 'form' })) + .attach('template9', `${__dirname}/template9-complete.xlsx`); + + expect(response.status).toBe(200); + }); }); From 557f4663ba4ca36607ae1632ae5f1905ed0246f2 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Thu, 6 Feb 2025 16:57:54 -0500 Subject: [PATCH 08/10] chore: update test to provide more coverage --- .../form/[id]/rfi/[applicantRfiId].test.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx index 5a16d0bce1..625363e01a 100644 --- a/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx +++ b/app/tests/pages/applicantportal/form/[id]/rfi/[applicantRfiId].test.tsx @@ -589,7 +589,7 @@ describe('The applicantRfiId Page', () => { finalEligibleHouseholds: 4, }; const mockFetchPromiseTemplateOne = Promise.resolve({ - ok: false, + ok: true, status: 200, json: () => Promise.resolve({ result: mockSuccessResponseTemplateOne }), }); @@ -599,7 +599,7 @@ describe('The applicantRfiId Page', () => { totalProjectCosts: 101230, }; const mockFetchPromiseTemplateTwo = Promise.resolve({ - ok: false, + ok: true, status: 200, json: () => Promise.resolve({ result: mockSuccessResponseTemplateTwo }), }); @@ -681,7 +681,7 @@ describe('The applicantRfiId Page', () => { }); }); - expect(global.fetch).toHaveBeenCalledTimes(5); + expect(global.fetch).toHaveBeenCalledTimes(3); expect(global.fetch).toHaveBeenCalledWith( '/api/applicant/template?templateNumber=1', { body: expect.any(FormData), method: 'POST' } @@ -702,7 +702,7 @@ describe('The applicantRfiId Page', () => { }); pageTestingHelper.expectMutationToBeCalled( - 'updateRfiAndCreateTemplateNineDataMutation', + 'updateFormRfiAndCreateTemplateNineDataMutation', { rfiInput: { jsonData: { @@ -773,6 +773,21 @@ describe('The applicantRfiId Page', () => { }, _errors: [], }, + formInput: { + applicationRowId: 1, + jsonData: { + benefits: { + householdsImpactedIndigenous: 60, + numberOfHouseholds: 4, + }, + budgetDetails: { + totalEligibleCosts: 92455, + totalProjectCost: 101230, + }, + }, + reasonForChange: 'Auto updated from upload for RFI: CCBC-01001-01', + formSchemaId: 'test', + }, } ); }); From 39e29cbed54c50691a3ecdb17036bbeb2e76cdb4 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Thu, 6 Feb 2025 17:05:07 -0500 Subject: [PATCH 09/10] chore: increase coverage on template_nine api --- .../lib/excel_import/template_nine.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/app/tests/backend/lib/excel_import/template_nine.test.ts b/app/tests/backend/lib/excel_import/template_nine.test.ts index a831a136ef..41b50a9750 100644 --- a/app/tests/backend/lib/excel_import/template_nine.test.ts +++ b/app/tests/backend/lib/excel_import/template_nine.test.ts @@ -342,4 +342,52 @@ describe('The Community Progress Report import', () => { expect(response.status).toBe(200); }); + + it('should reject if a guest tries to process applicant rfi endpoint', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_guest', + landingRoute: '/', + }; + }); + + const response = await request(app).post( + '/api/template-nine/rfi/applicant/1/CCBC-00001-1' + ); + + expect(response.status).toBe(404); + }); + + it('should return 400 if an applicant tries to process bad file', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_auth_user', + landingRoute: '/', + }; + }); + + const response = await request(app) + .post('/api/template-nine/rfi/applicant/1/CCBC-00001-1') + .set('Content-Type', 'application/json') + .set('Connection', 'keep-alive') + .field('data', JSON.stringify({ name: 'form' })) + .attach('template9', null); + + expect(response.status).toBe(400); + }); + + it('should return 400 if an applicant tries to hit endpoint with bad parameters', async () => { + mocked(getAuthRole).mockImplementation(() => { + return { + pgRole: 'ccbc_auth_user', + landingRoute: '/', + }; + }); + + const response = await request(app).post( + '/api/template-nine/rfi/applicant/null/rfi' + ); + + expect(response.status).toBe(400); + }); }); From e218457ffad499eea57e47debe4b881009874fc2 Mon Sep 17 00:00:00 2001 From: Anthony Bushara Date: Tue, 11 Mar 2025 18:49:58 -0400 Subject: [PATCH 10/10] refactor: refactor processing templates for validation for sonarcloud --- app/lib/theme/widgets/FileWidget.tsx | 191 ++++++++++-------- app/tests/components/Form/FileWidget.test.tsx | 179 ++++++++++++++++ 2 files changed, 284 insertions(+), 86 deletions(-) diff --git a/app/lib/theme/widgets/FileWidget.tsx b/app/lib/theme/widgets/FileWidget.tsx index 612457f75e..67acab8d6f 100644 --- a/app/lib/theme/widgets/FileWidget.tsx +++ b/app/lib/theme/widgets/FileWidget.tsx @@ -27,6 +27,101 @@ interface FileWidgetProps extends WidgetProps { value: Array; } +export async function processFileTemplate( + file: any, + setTemplateData: Function, + templateNumber: number, + isApplicantPage: boolean, + formId: number, + rfiNumber: string +) { + let isTemplateValid = true; + const fileFormData = new FormData(); + if (file) { + fileFormData.append('file', file); + if (setTemplateData) { + try { + if (templateNumber !== 9) { + const response = await fetch( + `/api/applicant/template?templateNumber=${templateNumber}`, + { + method: 'POST', + body: fileFormData, + } + ); + if (response.ok) { + const data = await response.json(); + setTemplateData({ + templateNumber, + data, + templateName: file.name, + }); + } else { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); + } + } else if (templateNumber === 9) { + if (isApplicantPage) { + // fetch for applicant and handle as expected + const response = await fetch( + `/api/template-nine/rfi/applicant/${formId}/${rfiNumber}`, + { + method: 'POST', + body: fileFormData, + } + ); + if (response.ok) { + const data = await response.json(); + setTemplateData({ + templateNumber, + data, + templateName: file.name, + }); + } else { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); + } + } else { + const response = await fetch( + `/api/template-nine/rfi/${formId}/${rfiNumber}`, + { + method: 'POST', + body: fileFormData, + } + ); + if (response.ok) { + await response.json(); + setTemplateData({ + templateNumber, + templateName: file.name, + }); + } else { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); + } + } + } + } catch (error) { + isTemplateValid = false; + setTemplateData({ + templateNumber, + error: true, + }); + } + } + } + return isTemplateValid; +} + const FileWidget: React.FC = ({ id, disabled, @@ -81,92 +176,16 @@ const FileWidget: React.FC = ({ }, [rawErrors, setErrors]); const getValidatedFile = async (file: any, formId: number) => { - let isTemplateValid = true; - if (templateValidate) { - const fileFormData = new FormData(); - if (file) { - fileFormData.append('file', file); - if (setTemplateData) { - try { - if (templateNumber !== 9) { - const response = await fetch( - `/api/applicant/template?templateNumber=${templateNumber}`, - { - method: 'POST', - body: fileFormData, - } - ); - if (response.ok) { - const data = await response.json(); - setTemplateData({ - templateNumber, - data, - templateName: file.name, - }); - } else { - isTemplateValid = false; - setTemplateData({ - templateNumber, - error: true, - }); - } - } else if (templateNumber === 9) { - if (isApplicantPage) { - // fetch for applicant and handle as expected - const response = await fetch( - `/api/template-nine/rfi/applicant/${formId}/${rfiNumber}`, - { - method: 'POST', - body: fileFormData, - } - ); - if (response.ok) { - const data = await response.json(); - setTemplateData({ - templateNumber, - data, - templateName: file.name, - }); - } else { - isTemplateValid = false; - setTemplateData({ - templateNumber, - error: true, - }); - } - } else { - const response = await fetch( - `/api/template-nine/rfi/${formId}/${rfiNumber}`, - { - method: 'POST', - body: fileFormData, - } - ); - if (response.ok) { - await response.json(); - setTemplateData({ - templateNumber, - templateName: file.name, - }); - } else { - isTemplateValid = false; - setTemplateData({ - templateNumber, - error: true, - }); - } - } - } - } catch (error) { - isTemplateValid = false; - setTemplateData({ - templateNumber, - error: true, - }); - } - } - } - } + const isTemplateValid = templateValidate + ? await processFileTemplate( + file, + setTemplateData, + templateNumber, + isApplicantPage, + formId, + rfiNumber + ) + : true; const { name, size, type } = file; const { isValid, error: newError } = validateFile( diff --git a/app/tests/components/Form/FileWidget.test.tsx b/app/tests/components/Form/FileWidget.test.tsx index 6c6992ab49..ff01e10cb6 100644 --- a/app/tests/components/Form/FileWidget.test.tsx +++ b/app/tests/components/Form/FileWidget.test.tsx @@ -1,4 +1,5 @@ import ApplicationForm from 'components/Form/ApplicationForm'; +import { processFileTemplate } from 'lib/theme/widgets/FileWidget'; import { graphql } from 'react-relay'; import compiledQuery, { FileWidgetTestQuery, @@ -537,6 +538,184 @@ describe('The FileWidget', () => { ); }); + describe('processFileTemplate function tests', () => { + // Keep a reference to the original fetch. + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + global.fetch = originalFetch; + }); + + const file = { name: 'test.txt' }; + + test('should return true and do nothing if file is not provided', async () => { + const setTemplateData = jest.fn(); + // file: null, setTemplateData, templateNumber: 1 } + const result = await processFileTemplate(null, setTemplateData, 1); + expect(result).toBe(true); + expect(global.fetch).not.toHaveBeenCalled(); + expect(setTemplateData).not.toHaveBeenCalled(); + }); + + test('should process template (non-9) when response is ok', async () => { + const mockData = { key: 'value' }; + global.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockData), + }); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 5, + false, + 1, + 'rfi-1' + ); + expect(global.fetch).toHaveBeenCalledWith( + `/api/applicant/template?templateNumber=5`, + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }) + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 5, + data: mockData, + templateName: file.name, + }); + expect(result).toBe(true); + }); + + test('should set error for non-9 template when response is not ok', async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: jest.fn(), + }); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 3, + false, + 1, + 'rfi-1' + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 3, + error: true, + }); + expect(result).toBe(false); + }); + + test('should catch error for non-9 template when fetch throws', async () => { + global.fetch.mockRejectedValue(new Error('Network error')); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 2, + false, + 1, + 'rfi-1' + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 2, + error: true, + }); + expect(result).toBe(false); + }); + + test('should process template 9 when response is ok', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + }); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 9, + false, + 'form123', + 'rfi456' + ); + expect(global.fetch).toHaveBeenCalledWith( + `/api/template-nine/rfi/form123/rfi456`, + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }) + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 9, + templateName: file.name, + }); + expect(result).toBe(true); + }); + + test('should process applicant template 9 when response is ok', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({}), + }); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 9, + true, + 'form123', + 'rfi456' + ); + expect(global.fetch).toHaveBeenCalledWith( + `/api/template-nine/rfi/applicant/form123/rfi456`, + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }) + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 9, + templateName: file.name, + data: {}, + }); + expect(result).toBe(true); + }); + + test('should set error for template 9 when response is not ok', async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: jest.fn(), + }); + const setTemplateData = jest.fn(); + const result = await processFileTemplate( + file, + setTemplateData, + 9, + 'form123', + 'rfi456' + ); + expect(setTemplateData).toHaveBeenCalledWith({ + templateNumber: 9, + error: true, + }); + expect(result).toBe(false); + }); + + test('should return true if setTemplateData is not provided', async () => { + const result = await processFileTemplate(file, undefined, 5); + // With no setTemplateData, no fetch is performed and isTemplateValid remains true. + expect(global.fetch).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); + afterEach(() => { jest.clearAllMocks(); });