diff --git a/packages/admin-panel/.env.example b/packages/admin-panel/.env.example index 4236bd19c7..6f659beb92 100644 --- a/packages/admin-panel/.env.example +++ b/packages/admin-panel/.env.example @@ -3,3 +3,4 @@ REACT_APP_CLIENT_BASIC_AUTH_HEADER= REACT_APP_VIZ_BUILDER_API_URL= SKIP_PREFLIGHT_CHECK= PARSE_LINK_HEADER_MAXLEN= +REACT_APP_DATATRAK_WEB_URL= diff --git a/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js b/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js index abc6778472..ee6fc40413 100644 --- a/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js +++ b/packages/admin-panel/src/VizBuilderApp/api/queries/useEntities.js @@ -12,7 +12,7 @@ export const useEntities = search => ['entities', search], async () => { const endpoint = stringifyQuery(undefined, `entities`, { - columns: JSON.stringify(['name', 'code', 'id']), + columns: JSON.stringify(['name', 'code', 'id', 'country_code']), filter: JSON.stringify({ name: { comparator: 'ilike', comparisonValue: `%${search}%`, castAs: 'text' }, }), diff --git a/packages/admin-panel/src/api/mutations/useEditSurveyResponse.js b/packages/admin-panel/src/api/mutations/useEditSurveyResponse.js new file mode 100644 index 0000000000..e542311339 --- /dev/null +++ b/packages/admin-panel/src/api/mutations/useEditSurveyResponse.js @@ -0,0 +1,26 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { useApiContext } from '../../utilities/ApiProvider'; + +export const useEditSurveyResponse = (surveyResponseId, updatedSurveyResponse) => { + const queryClient = useQueryClient(); + const api = useApiContext(); + return useMutation( + [`surveyResponseEdit`, surveyResponseId, updatedSurveyResponse], + () => { + return api.put(`surveyResponses/${surveyResponseId}`, null, updatedSurveyResponse); + }, + { + throwOnError: true, + onSuccess: async () => { + // invalidate the survey response data + await queryClient.invalidateQueries(['surveyResubmitData', surveyResponseId]); + return 'completed'; + }, + }, + ); +}; diff --git a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js b/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js deleted file mode 100644 index 840929d77b..0000000000 --- a/packages/admin-panel/src/api/mutations/useResubmitSurveyResponse.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Tupaia - * Copyright (c) 2017 - 20211Beyond Essential Systems Pty Ltd - * - */ -import { useMutation } from 'react-query'; -import { useApiContext } from '../../utilities/ApiProvider'; - -export const useResubmitSurveyResponse = ( - surveyResponseId, - updatedSurveyResponse, - filesByQuestionCode, -) => { - const api = useApiContext(); - return useMutation( - [`surveyResubmit`, surveyResponseId, updatedSurveyResponse], - () => { - return api.multipartPost({ - endpoint: `surveyResponse/${surveyResponseId}/resubmit`, - filesByMultipartKey: filesByQuestionCode, - payload: { - ...updatedSurveyResponse, - }, - }); - }, - { - throwOnError: true, - }, - ); -}; diff --git a/packages/admin-panel/src/routes/surveys/surveyResponses.js b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx similarity index 77% rename from packages/admin-panel/src/routes/surveys/surveyResponses.js rename to packages/admin-panel/src/routes/surveys/surveyResponses.jsx index 7399838e89..31ef7824ed 100644 --- a/packages/admin-panel/src/routes/surveys/surveyResponses.js +++ b/packages/admin-panel/src/routes/surveys/surveyResponses.jsx @@ -2,21 +2,45 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - +import React from 'react'; import { getBrowserTimeZone } from '@tupaia/utils'; import moment from 'moment'; -import { ApprovalStatus } from '@tupaia/types'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; import { SurveyResponsesExportModal } from '../../importExport'; import { getPluralForm } from '../../pages/resources/resourceName'; +import { OutdatedFilter } from '../../table/columnTypes/columnFilters'; const RESOURCE_NAME = { singular: 'survey response' }; -// Don't include not_required as an editable option because it can lead to -// mis-matches between surveys and survey responses -export const APPROVAL_STATUS_TYPES = Object.values(ApprovalStatus).map(type => ({ - label: type, - value: type, -})); +const GREEN = '#47CA80'; +const GREY = '#898989'; + +const Pill = styled.span` + background-color: ${({ $color }) => { + return `${$color}33`; // slightly transparent + }}; + border-radius: 1.5rem; + padding: 0.3rem 0.9rem; + color: ${({ $color }) => $color}; + .cell-content:has(&) > div { + overflow: visible; + } +`; + +const ResponseStatusPill = ({ value }) => { + const text = value ? 'Outdated' : 'Current'; + const color = value ? GREY : GREEN; + return {text}; +}; + +ResponseStatusPill.propTypes = { + value: PropTypes.bool, +}; + +ResponseStatusPill.defaultProps = { + value: false, +}; const surveyName = { Header: 'Survey', @@ -56,9 +80,13 @@ const dateOfData = { }, }; -const approvalStatus = { - Header: 'Approval status', - source: 'approval_status', +const responseStatus = { + Header: 'Response status', + source: 'outdated', + Filter: OutdatedFilter, + width: 180, + // eslint-disable-next-line react/prop-types + Cell: ({ value }) => , }; const entityName = { @@ -80,7 +108,7 @@ export const SURVEY_RESPONSE_COLUMNS = [ assessorName, date, dateOfData, - approvalStatus, + responseStatus, { Header: 'Export', type: 'export', diff --git a/packages/admin-panel/src/surveyResponse/Form.jsx b/packages/admin-panel/src/surveyResponse/Form.jsx index a904216c30..4bfbcd9e76 100644 --- a/packages/admin-panel/src/surveyResponse/Form.jsx +++ b/packages/admin-panel/src/surveyResponse/Form.jsx @@ -5,52 +5,38 @@ import React, { useState, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; +import styled from 'styled-components'; import { Button } from '@tupaia/ui-components'; -import { Divider } from '@material-ui/core'; import { useGetExistingData } from './useGetExistingData'; import { ModalContentProvider, ModalFooter } from '../widgets'; -import { useResubmitSurveyResponse } from '../api/mutations/useResubmitSurveyResponse'; -import { MODAL_STATUS } from './constants'; -import { SurveyScreens } from './SurveyScreens'; +import { useEditSurveyResponse } from '../api/mutations/useEditSurveyResponse'; import { ResponseFields } from './ResponseFields'; +const ButtonGroup = styled.div` + display: flex; + justify-content: space-between; + width: 100%; +`; + export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { - const [surveyResubmission, setSurveyResubmission] = useState({}); - const [filesByQuestionCode, setFilesByQuestionCode] = useState({}); - const isUnchanged = Object.keys(surveyResubmission).length === 0; - const [resubmitStatus, setResubmitStatus] = useState(MODAL_STATUS.INITIAL); + const [editedData, setEditedData] = useState({}); + const isUnchanged = Object.keys(editedData).length === 0; const [selectedEntity, setSelectedEntity] = useState({}); - const [resubmitError, setResubmitError] = useState(null); - - const useResubmitResponse = () => { - // Swap filesByQuestionCode to filesByUniqueFileName. - // Tracking by question code allows us to manage files easier e.g. don't have to worry about tracking them in deletions - // And the API endpoint needs them by uniqueFileName - const filesByUniqueFileName = {}; - for (const [questionCode, file] of Object.entries(filesByQuestionCode)) { - const uniqueFileName = surveyResubmission.answers[questionCode]; - filesByUniqueFileName[uniqueFileName] = file; - } - return useResubmitSurveyResponse(surveyResponseId, surveyResubmission, filesByUniqueFileName); - }; - const { mutateAsync: resubmitResponse } = useResubmitResponse(); - const handleResubmit = useCallback(async () => { - setResubmitStatus(MODAL_STATUS.LOADING); - try { - await resubmitResponse(); - } catch (e) { - setResubmitStatus(MODAL_STATUS.ERROR); - setResubmitError(e); - return; - } - setResubmitStatus(MODAL_STATUS.SUCCESS); - onAfterMutate(); - }); + const { + mutateAsync: editResponse, + isLoading, + isError, + error: editError, + reset, // reset the mutation state so we can dismiss the error + isSuccess, + } = useEditSurveyResponse(surveyResponseId, editedData); const { data, isLoading: isFetching, error: fetchError } = useGetExistingData(surveyResponseId); + const existingAndNewFields = { ...data?.surveyResponse, ...editedData }; + useEffect(() => { if (!data) { setSelectedEntity({}); @@ -59,88 +45,81 @@ export const Form = ({ surveyResponseId, onDismiss, onAfterMutate }) => { } }, [data]); - const handleDismissError = () => { - setResubmitStatus(MODAL_STATUS.INITIAL); - setResubmitError(null); + const resubmitSurveyResponse = async () => { + await editResponse(); + onAfterMutate(); }; - const onSetFormFile = (questionCode, file) => { - setFilesByQuestionCode({ ...filesByQuestionCode, [questionCode]: file }); + const getDatatrakBaseUrl = () => { + if (import.meta.env.REACT_APP_DATATRAK_WEB_URL) + return import.meta.env.REACT_APP_DATATRAK_WEB_URL; + const { origin } = window.location; + if (origin.includes('localhost')) return 'https://dev-datatrak.tupaia.org'; + return origin.replace('admin', 'datatrak'); }; - const renderButtons = useCallback(() => { - switch (resubmitStatus) { - case MODAL_STATUS.LOADING: - return <>>; - case MODAL_STATUS.ERROR: - return ( - <> - handleDismissError()}> - Dismiss - - > - ); - case MODAL_STATUS.SUCCESS: - return ( - <> - Close - > - ); - case MODAL_STATUS.INITIAL: - default: - return ( - <> - - Cancel - - handleResubmit()} - disabled={isFetching || isUnchanged} - > - Resubmit - - > - ); + const resubmitResponseAndRedirect = async () => { + // If the response has been changed, resubmit it before redirecting + if (!isUnchanged) { + await editResponse(); + onAfterMutate(); } - }, [resubmitStatus, isFetching, isUnchanged]); + const { country_code: updatedCountryCode } = selectedEntity; + const { survey, primaryEntity } = data; + const countryCodeToUse = updatedCountryCode || primaryEntity.country_code; + const datatrakBaseUrl = getDatatrakBaseUrl(); + const url = `${datatrakBaseUrl}/survey/${countryCodeToUse}/${survey.code}/resubmit/${surveyResponseId}`; + // Open the URL in a new tab, so the user can resubmit the response in Datatrak + window.open(url, '_blank'); + }; - const existingAndNewFields = { ...data?.surveyResponse, ...surveyResubmission }; - const isResubmitting = resubmitStatus === MODAL_STATUS.LOADING; - const isResubmitSuccess = resubmitStatus === MODAL_STATUS.SUCCESS; + const renderButtons = useCallback(() => { + if (isLoading) return null; + if (isError) + return ( + + Dismiss + + ); + if (isSuccess) return Close; + return ( + + + Save and close + + + + Cancel + + + Next + + + + ); + }, [isFetching, isUnchanged, isLoading, isError, isSuccess]); return ( <> - - {!isFetching && !isResubmitSuccess && ( - <> - - setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) - } - setSelectedEntity={setSelectedEntity} - /> - - - setSurveyResubmission({ ...surveyResubmission, [field]: updatedField }) - } - onSetFormFile={onSetFormFile} - survey={data?.survey} - existingAnswers={data?.answers} - selectedEntity={selectedEntity} - fields={existingAndNewFields} - /> - > + + {!isFetching && !isSuccess && ( + + setEditedData({ ...editedData, [field]: updatedField }) + } + setSelectedEntity={setSelectedEntity} + /> )} - {isResubmitSuccess && 'The survey response has been successfully submitted.'} + {isSuccess && 'The survey response has been successfully submitted.'} {renderButtons()} > diff --git a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx index 544b50125b..ab593f8047 100644 --- a/packages/admin-panel/src/surveyResponse/ResponseFields.jsx +++ b/packages/admin-panel/src/surveyResponse/ResponseFields.jsx @@ -3,12 +3,9 @@ * Copyright (c) 2023 Beyond Essential Systems Pty Ltd */ -/* eslint-disable camelcase */ - import React, { useState } from 'react'; import styled from 'styled-components'; import PropTypes from 'prop-types'; -import Paper from '@material-ui/core/Paper'; import { Typography } from '@material-ui/core'; import { Select, DateTimePicker } from '@tupaia/ui-components'; import { ApprovalStatus } from '@tupaia/types'; @@ -18,26 +15,52 @@ import { useDebounce } from '../utilities'; import { useEntities } from '../VizBuilderApp/api'; import { EntityOptionLabel } from '../widgets'; -const SectionWrapper = styled.div` - display: grid; - grid-template-columns: 1fr 1fr 1fr; - column-gap: 10px; +const InputSection = styled.div` + margin-block-start: 1.25rem; + margin-block-end: 1.2rem; +`; + +const BorderedSection = styled.div` + border: 1px solid ${props => props.theme.palette.grey['400']}; + border-radius: 4px; + padding: 1.1rem; +`; + +const Row = styled.div` + display: flex; + justify-content: space-between; + > div { + width: 49%; + } + .MuiFormControl-root { + margin-block-end: 0; + } +`; + +const ResponseFieldHeading = styled(Typography).attrs({ + variant: 'h3', +})` + padding-block-end: 0.2rem; + font-size: ${props => props.theme.typography.body2.fontSize}; + font-weight: ${props => props.theme.typography.fontWeightRegular}; + color: ${props => props.theme.palette.text.secondary}; `; -const ResponseFieldHeading = styled(Typography)` - font-weight: 500; - padding-bottom: 0.5rem; +const ResponseFieldValue = styled(Typography)` + font-weight: ${props => props.theme.typography.fontWeightMedium}; `; const ResponseFieldWrapper = styled.div` - padding: 1rem 0; + + ${Row} { + margin-block-start: 1.25rem; + } `; const ResponseField = ({ title, value }) => { return ( - {title} - {value} + {title} + {value} ); }; @@ -66,14 +89,18 @@ export const ResponseFields = ({ const limitedLocations = entities.slice(0, 20); return ( - - + <> + - - + + + + + + - { - onChange('data_time', format(AESTDate, 'yyyy-MM-dd HH:mm:ss')); - }} - /> - - { - onChange('approval_status', event.target.value); - }} - value={fields.approval_status} - /> - - + + { + onChange('data_time', format(AESTDate, 'yyyy-MM-dd HH:mm:ss')); + }} + /> + { + onChange('approval_status', event.target.value); + }} + value={fields.approval_status} + /> + + + > ); }; diff --git a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx index c4c1356c06..bd03253ade 100644 --- a/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx +++ b/packages/admin-panel/src/surveyResponse/ResubmitSurveyResponseModal.jsx @@ -18,7 +18,7 @@ export const ResubmitSurveyResponseModalComponent = ({ onAfterMutate, }) => { return ( - + { +export const ResubmitSurveyResponseButtonComponent = ({ openModal, row }) => { + if (row.original.outdated) return null; return ( @@ -20,6 +21,11 @@ export const ResubmitSurveyResponseButtonComponent = ({ openModal }) => { ResubmitSurveyResponseButtonComponent.propTypes = { openModal: PropTypes.func.isRequired, + row: PropTypes.shape({ + original: PropTypes.shape({ + outdated: PropTypes.bool.isRequired, + }).isRequired, + }).isRequired, }; const mapDispatchToProps = (dispatch, ownProps) => { diff --git a/packages/admin-panel/src/table/columnTypes/columnFilters.jsx b/packages/admin-panel/src/table/columnTypes/columnFilters.jsx index 2bb2eea429..a5ea40fe9c 100644 --- a/packages/admin-panel/src/table/columnTypes/columnFilters.jsx +++ b/packages/admin-panel/src/table/columnTypes/columnFilters.jsx @@ -72,6 +72,41 @@ BooleanSelectFilter.defaultProps = { filter: {}, }; +/* + * Makes outdated field work with the database filter + */ + +export const OutdatedFilter = ({ filter, onChange, column }) => { + return ( + onChange(e.target.value)} + value={filter?.value ?? ''} + /> + ); +}; + +OutdatedFilter.propTypes = { + column: PropTypes.PropTypes.shape({ + id: PropTypes.string, + }), + filter: PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired, + }), + onChange: PropTypes.func, +}; + +OutdatedFilter.defaultProps = { + onChange: null, + column: {}, + filter: {}, +}; + export const VerifiedFilter = ({ filter, onChange, column }) => { return ( { + return this.connection.post( + `surveyResponses/${originalResponseId}/resubmit`, + queryParameters, + newResponse, + ); + } + public async fetchResources(endpoint: string, params?: Record) { return this.connection.get(endpoint, stringifyParams(params)); } diff --git a/packages/api-client/src/connections/mocks/MockCentralApi.ts b/packages/api-client/src/connections/mocks/MockCentralApi.ts index 55efc849c2..b750be91f0 100644 --- a/packages/api-client/src/connections/mocks/MockCentralApi.ts +++ b/packages/api-client/src/connections/mocks/MockCentralApi.ts @@ -79,6 +79,12 @@ export class MockCentralApi implements CentralApiInterface { public createSurveyResponses(responses: MeditrakSurveyResponseRequest[]): Promise { throw new Error('Method not implemented.'); } + public resubmitSurveyResponse( + originalResponseId: string, + newResponse: MeditrakSurveyResponseRequest, + ): Promise { + throw new Error('Method not implemented.'); + } public async fetchResources(endpoint: string, params?: Params): Promise { const resourceData = this.mockData[endpoint]; if (!resourceData) return []; diff --git a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js index 2d1df9a688..c5c1222825 100644 --- a/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js +++ b/packages/central-server/src/apiV2/export/exportSurveyResponses/exportResponsesToFile.js @@ -165,6 +165,7 @@ export async function exportResponsesToFile( const findResponsesForSurvey = async survey => { const surveyResponseFindConditions = { survey_id: survey.id, + outdated: false, // only get the latest version of each survey response. Outdated responses are usually resubmissions }; const dataTimeCondition = getDataTimeCondition(); if (dataTimeCondition) { diff --git a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js index efdcfd6f75..29c7f8fcc1 100644 --- a/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js +++ b/packages/central-server/src/apiV2/import/importSurveys/Validator/ConfigValidator/EntityConfigValidator.js @@ -90,6 +90,9 @@ export class EntityConfigValidator extends JsonFieldValidator { pointsToPrecedingEntityQuestion(...params), ); + const pointsToMandatoryCodeGeneratorQuestion = + this.constructPointsToMandatoryCodeGeneratorQuestion(rowIndex); + const pointsToAnotherMandatoryQuestion = this.constructPointsToMandatoryQuestion(rowIndex); return { @@ -100,7 +103,7 @@ export class EntityConfigValidator extends JsonFieldValidator { 'attributes.type': [constructIsNotPresentOr(pointsToAnotherQuestion)], 'fields.code': [ hasContentOnlyIfCanCreateNew, - constructIsNotPresentOr(pointsToAnotherMandatoryQuestion), + constructIsNotPresentOr(pointsToMandatoryCodeGeneratorQuestion), ], 'fields.name': [ hasContentIfCanCreateNew, @@ -123,6 +126,23 @@ export class EntityConfigValidator extends JsonFieldValidator { }; } + constructPointsToMandatoryCodeGeneratorQuestion(rowIndex) { + return value => { + const questionCode = value; + const question = this.findOtherQuestion(questionCode, rowIndex, this.questions.length); + + if (!question) { + throw new ValidationError(`Should reference another code generator question in the survey`); + } + + if (question.type !== 'CodeGenerator') { + throw new ValidationError(`Referenced question should be a code generator question`); + } + + return true; + }; + } + constructPointsToMandatoryQuestion(rowIndex) { return value => { const questionCode = value; @@ -141,7 +161,7 @@ export class EntityConfigValidator extends JsonFieldValidator { if (!parsedValidationCriteria.mandatory || parsedValidationCriteria.mandatory !== 'true') { throw new ValidationError(`Referenced question should be mandatory`); - } + } return true; }; diff --git a/packages/central-server/src/apiV2/index.js b/packages/central-server/src/apiV2/index.js index bf2a97dc7f..1d605fed9c 100644 --- a/packages/central-server/src/apiV2/index.js +++ b/packages/central-server/src/apiV2/index.js @@ -73,6 +73,7 @@ import { GETSurveyResponses, SubmitSurveyResponses, ResubmitSurveyResponse, + EditSurveyResponse, } from './surveyResponses'; import { DeleteSurveyScreenComponents, @@ -286,7 +287,7 @@ apiV2.post('/me/changePassword', catchAsyncErrors(changePassword)); apiV2.post('/surveyResponse', useRouteHandler(SubmitSurveyResponses)); // used by mSupply to directly submit data apiV2.post('/surveyResponses', useRouteHandler(SubmitSurveyResponses)); apiV2.post( - '/surveyResponse/:recordId/resubmit', + '/surveyResponses/:recordId/resubmit', multipartJson(false), useRouteHandler(ResubmitSurveyResponse), ); @@ -354,6 +355,7 @@ apiV2.put('/surveys/:recordId', multipartJson(), useRouteHandler(EditSurvey)); apiV2.put('/dhisInstances/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/supersetInstances/:recordId', useRouteHandler(BESAdminEditHandler)); apiV2.put('/tasks/:recordId', useRouteHandler(EditTask)); +apiV2.put('/surveyResponses/:recordId', useRouteHandler(EditSurveyResponse)); /** * DELETE routes diff --git a/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js b/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js new file mode 100644 index 0000000000..0748b354b2 --- /dev/null +++ b/packages/central-server/src/apiV2/surveyResponses/EditSurveyResponse.js @@ -0,0 +1,25 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { assertAnyPermissions, assertBESAdminAccess } from '../../permissions'; +import { EditHandler } from '../EditHandler'; +import { assertSurveyResponsePermissions } from './assertSurveyResponsePermissions'; + +export class EditSurveyResponse extends EditHandler { + async assertUserHasAccess() { + // Check the user has either: + // - BES admin access + // - Permission to view the surveyResponse AND Tupaia Admin Panel access anywhere + const surveyResponsePermissionChecker = accessPolicy => + assertSurveyResponsePermissions(accessPolicy, this.models, this.recordId); + await this.assertPermissions( + assertAnyPermissions([assertBESAdminAccess, surveyResponsePermissionChecker]), + ); + } + + async editRecord() { + await this.updateRecord(); + } +} diff --git a/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js b/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js index 099ce8802a..0c70d55ea1 100644 --- a/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js +++ b/packages/central-server/src/apiV2/surveyResponses/ResubmitSurveyResponse.js @@ -4,95 +4,80 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import { S3, S3Client } from '@tupaia/server-utils'; -import { AnalyticsRefresher } from '@tupaia/database'; -import fs from 'fs'; -import { EditHandler } from '../EditHandler'; +import { respond } from '@tupaia/utils'; import { assertAllPermissions, assertAnyPermissions, assertBESAdminAccess, assertAdminPanelAccess, } from '../../permissions'; -import { - assertSurveyResponsePermissions, - assertSurveyResponseEditPermissions, -} from './assertSurveyResponsePermissions'; -import { handleSurveyResponse, handleAnswers } from './resubmission/handleResubmission'; -import { validateResubmission } from './resubmission/validateResubmission'; +import { assertSurveyResponsePermissions } from './assertSurveyResponsePermissions'; +import { RouteHandler } from '../RouteHandler'; +import { validateSurveyResponse } from './validateSurveyResponses'; +import { assertCanSubmitSurveyResponses } from '../import/importSurveyResponses/assertCanImportSurveyResponses'; +import { upsertEntitiesAndOptions } from './upsertEntitiesAndOptions'; +import { saveResponsesToDatabase } from './saveResponsesToDatabase'; /** * Handles POST endpoint: * - /surveyResponses/:surveyResponseId/resubmit - * handles both edits and creation of new answers + * + * Creates a new survey response and flags the previous one as `outdated=true` */ -export class ResubmitSurveyResponse extends EditHandler { +export class ResubmitSurveyResponse extends RouteHandler { + constructor(req, res) { + super(req, res); + this.originalSurveyResponseId = this.params.recordId; + this.newSurveyResponse = req.body; + } + async assertUserHasAccess() { // Check the user has either: // - BES admin access - // - Permission to view the surveyResponse AND Tupaia Admin Panel access anywhere - const surveyResponsePermissionChecker = accessPolicy => - assertSurveyResponsePermissions(accessPolicy, this.models, this.recordId); + // - Tupaia Admin Panel access AND permission to view the surveyResponse AND permission to submit the new survey response + const originalSurveyResponsePermissionChecker = accessPolicy => + assertSurveyResponsePermissions(accessPolicy, this.models, this.originalSurveyResponseId); + + const newSurveyResponsePermissionsChecker = async accessPolicy => { + await assertCanSubmitSurveyResponses(accessPolicy, this.models, [this.newSurveyResponse]); + }; await this.assertPermissions( assertAnyPermissions([ assertBESAdminAccess, - assertAllPermissions([assertAdminPanelAccess, surveyResponsePermissionChecker]), + assertAllPermissions([ + assertAdminPanelAccess, + originalSurveyResponsePermissionChecker, + newSurveyResponsePermissionsChecker, + ]), ]), ); } - async editRecord() { - // Check we aren't editing the surveyResponse in a way that could break something - const surveyResponseEditPermissionChecker = accessPolicy => - assertSurveyResponseEditPermissions( - accessPolicy, - this.models, - this.recordId, - this.updatedFields, - ); - await this.assertPermissions( - assertAnyPermissions([assertBESAdminAccess, surveyResponseEditPermissionChecker]), + async handleRequest() { + // Upsert entities and options that were created in user's local database + const originalSurveyResponse = await this.models.surveyResponse.findById( + this.originalSurveyResponseId, ); + if (!originalSurveyResponse) { + throw new Error( + `Cannot find original survey response with id: ${this.originalSurveyResponseId}`, + ); + } await this.models.wrapInTransaction(async transactingModels => { - const currentSurveyResponse = await transactingModels.surveyResponse.findOne({ - id: this.recordId, + await upsertEntitiesAndOptions(transactingModels, [this.newSurveyResponse]); + await validateSurveyResponse(transactingModels, this.newSurveyResponse); + await this.assertUserHasAccess(); + await saveResponsesToDatabase(transactingModels, originalSurveyResponse.user_id, [ + this.newSurveyResponse, + ]); + await transactingModels.surveyResponse.updateById(this.originalSurveyResponseId, { + outdated: true, }); - if (!currentSurveyResponse) { - throw Error('Survey response not found.'); - } - await validateResubmission(transactingModels, this.updatedFields, currentSurveyResponse); - await handleAnswers(this.models, this.updatedFields, currentSurveyResponse); - await handleSurveyResponse( - this.models, - this.updatedFields, - this.recordType, - currentSurveyResponse, - ); - if (this.req.files) { - // Upload files last so that we don't end up with uploaded files if db changes fail - for (const file of this.req.files) { - const uniqueFileName = file.fieldname; - const readableStream = fs.createReadStream(file.path); // see https://github.com/aws/aws-sdk-js-v3/issues/2522 - const s3Client = this.getS3client(); - await s3Client.uploadFile(uniqueFileName, readableStream); - } - } - - if (this.req.query.waitForAnalyticsRebuild) { - const { database } = transactingModels; - await AnalyticsRefresher.refreshAnalytics(database); - } }); - } - // Workaround to allow us to test this route, remove after S3Client is mocked after RN-982 - getS3client() { - if (!this.s3Client) { - this.s3Client = new S3Client(new S3()); - } - return this.s3Client; + respond(this.res, { message: 'Successfully resubmitted the response' }); } } diff --git a/packages/central-server/src/apiV2/surveyResponses/index.js b/packages/central-server/src/apiV2/surveyResponses/index.js index 89c2345890..c7ee4a19d6 100644 --- a/packages/central-server/src/apiV2/surveyResponses/index.js +++ b/packages/central-server/src/apiV2/surveyResponses/index.js @@ -11,3 +11,4 @@ export { upsertEntitiesAndOptions } from './upsertEntitiesAndOptions'; export { validateSurveyResponses } from './validateSurveyResponses'; export { ResubmitSurveyResponse } from './ResubmitSurveyResponse'; export { SubmitSurveyResponses } from './SubmitSurveyResponses'; +export { EditSurveyResponse } from './EditSurveyResponse'; diff --git a/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js b/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js deleted file mode 100644 index cf7c0f2870..0000000000 --- a/packages/central-server/src/apiV2/surveyResponses/resubmission/handleResubmission.js +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable camelcase */ -/** - * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd - */ - -import { findQuestionsInSurvey } from '../../../dataAccessors'; - -export const handleSurveyResponse = async (models, updatedFields, recordType, surveyResponse) => { - const surveyResponseUpdateFields = { ...updatedFields }; - delete surveyResponseUpdateFields.answers; - if (Object.keys(surveyResponseUpdateFields).length < 1) { - return; - } - await models.surveyResponse.updateById(surveyResponse.id, surveyResponseUpdateFields); -}; - -export const handleAnswers = async (models, updatedFields, surveyResponse) => { - const { answers: updatedAnswers } = updatedFields; - - if (!updatedAnswers) { - return; - } - // check answer exists - const { survey_id: surveyId, id: surveyResponseId } = surveyResponse; - const surveyQuestions = await findQuestionsInSurvey(models, surveyId); - const codesToIds = {}; - surveyQuestions.forEach(({ id, code, type }) => { - codesToIds[code] = { id, type }; - }); - const questionCodes = Object.keys(updatedAnswers); - - await Promise.all( - questionCodes.map(async questionCode => { - const isAnswerDeletion = updatedAnswers[questionCode] === null; - const { id, type } = codesToIds[questionCode]; - const existingAnswer = await models.answer.findOne({ - survey_response_id: surveyResponseId, - question_id: id, - }); - - if (!existingAnswer) { - if (isAnswerDeletion) { - return; - } - await models.answer.create({ - type, - survey_response_id: surveyResponseId, - question_id: id, - text: updatedAnswers[questionCode], - }); - return; - } - - if (isAnswerDeletion) { - await models.answer.delete({ id: existingAnswer.id }); - return; - } - - await models.answer.update( - { id: existingAnswer.id }, - { - text: updatedAnswers[questionCode], - }, - ); - }), - ); -}; diff --git a/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js b/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js deleted file mode 100644 index bedb35a8ce..0000000000 --- a/packages/central-server/src/apiV2/surveyResponses/resubmission/validateResubmission.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2022-2023 Beyond Essential Systems Pty Ltd - */ - -import { - ValidationError, - ObjectValidator, - constructRecordExistsWithId, - constructIsEmptyOr, - takesDateForm, - isPlainObject, - isAString, -} from '@tupaia/utils'; -import { constructAnswerValidator } from '../../utilities/constructAnswerValidator'; -import { findQuestionsInSurvey } from '../../../dataAccessors'; - -export const validateResubmission = async (models, updatedFields, surveyResponse) => { - if (!updatedFields) { - throw new ValidationError('Survey responses must not be null'); - } - - const surveyResponseValidator = createSurveyResponseValidator(models); - await surveyResponseValidator.validate(updatedFields); - const { survey_id: surveyId } = surveyResponse; - const surveyQuestions = await findQuestionsInSurvey(models, surveyId); - - const { answers } = updatedFields; - if (!answers) { - return; - } - - const answerValidations = Object.entries(answers).map(async ([questionCode, value]) => { - const question = surveyQuestions.find(q => q.code === questionCode); - if (!question) { - throw new ValidationError( - `Could not find question with code ${questionCode} on survey ${surveyId}`, - ); - } - - if (value === null || value === undefined) { - // Answer deleted, valid - return; - } - - try { - const answerValidator = new ObjectValidator({}, constructAnswerValidator(models, question)); - await answerValidator.validate({ answer: value }); - } catch (e) { - // validator will always complain of field "answer" but in this context it is not - // particularly useful - throw new Error(e.message.replace('field "answer"', `question code "${questionCode}"`)); - } - }); - - await Promise.all(answerValidations); -}; - -const createSurveyResponseValidator = models => - new ObjectValidator({ - entity_id: [constructIsEmptyOr(constructRecordExistsWithId(models.entity))], - data_time: [constructIsEmptyOr(takesDateForm)], - end_time: [constructIsEmptyOr(takesDateForm)], - start_time: [constructIsEmptyOr(takesDateForm)], - answers: [constructIsEmptyOr(isPlainObject)], - approval_status: [constructIsEmptyOr(isAString)], - }); diff --git a/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js b/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js index 14a96bec75..c7a7790d63 100644 --- a/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js +++ b/packages/central-server/src/apiV2/surveyResponses/upsertEntitiesAndOptions.js @@ -10,18 +10,28 @@ const upsertEntities = async (models, entitiesUpserted, surveyId) => { const dataGroup = await survey.dataGroup(); return Promise.all( - entitiesUpserted.map(async entity => - models.entity.updateOrCreate( + entitiesUpserted.map(async entity => { + const existingEntity = await models.entity.findOne({ id: entity.id }); + + const existingMetadata = existingEntity?.metadata || {}; + + return models.entity.updateOrCreate( { id: entity.id }, { ...entity, metadata: dataGroup.service_type === 'dhis' - ? { dhis: { isDataRegional: !!dataGroup.config.isDataRegional } } + ? { + ...existingMetadata, + dhis: { + ...existingMetadata?.dhis, + isDataRegional: !!dataGroup.config.isDataRegional, + }, + } : {}, }, - ), - ), + ); + }), ); }; diff --git a/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js b/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js index 210ce6b0a5..5f6ad0c0af 100644 --- a/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js +++ b/packages/central-server/src/apiV2/surveyResponses/validateSurveyResponses.js @@ -34,7 +34,7 @@ const createSurveyResponseValidator = models => end_time: [constructIsEmptyOr(takesDateForm)], }); -const validateResponse = async (models, body) => { +export const validateSurveyResponse = async (models, body) => { if (!body) { throw new ValidationError('Survey responses must not be null'); } @@ -94,7 +94,7 @@ export const validateSurveyResponses = async (models, responses) => { const validations = await Promise.all( responses.map(async (r, i) => { try { - await validateResponse(models, r); + await validateSurveyResponse(models, r); return null; } catch (e) { return { row: i, error: e.message }; diff --git a/packages/central-server/src/dataAccessors/upsertAnswers.js b/packages/central-server/src/dataAccessors/upsertAnswers.js index 721e2b4d1c..07dadd80c9 100644 --- a/packages/central-server/src/dataAccessors/upsertAnswers.js +++ b/packages/central-server/src/dataAccessors/upsertAnswers.js @@ -18,9 +18,13 @@ export async function upsertAnswers(models, answers, surveyResponseId) { }; if (answer.type === QuestionType.Photo) { const validFileIdRegex = RegExp('^[a-f\\d]{24}$'); - if (validFileIdRegex.test(answer.body)) { + const s3ImagePath = getS3ImageFilePath(); + + if (answer.body.includes('http')) { + answerDocument.text = answer.body; + } else if (validFileIdRegex.test(answer.body)) { // if this is passed a valid id in the answer body - answerDocument.text = `${S3_BUCKET_PATH}${getS3ImageFilePath()}${answer.body}.png`; + answerDocument.text = `${S3_BUCKET_PATH}${s3ImagePath}${answer.body}.png`; } else { // included for backwards compatibility passing base64 strings for images, and for datatrak-web to upload images in answers try { @@ -38,10 +42,9 @@ export async function upsertAnswers(models, answers, surveyResponseId) { ) { try { const s3Client = new S3Client(new S3()); - answerDocument.text = await s3Client.uploadFile( - answer.body.uniqueFileName, - answer.body.data, - ); + await s3Client.uploadFile(answer.body.uniqueFileName, answer.body.data); + + answerDocument.text = answer.body.uniqueFileName; } catch (error) { throw new UploadError(error); } diff --git a/packages/central-server/src/dhis/DhisChangeValidator.js b/packages/central-server/src/dhis/DhisChangeValidator.js index 7712b36bf2..10c657dd68 100644 --- a/packages/central-server/src/dhis/DhisChangeValidator.js +++ b/packages/central-server/src/dhis/DhisChangeValidator.js @@ -7,7 +7,8 @@ import { getUniqueEntries } from '@tupaia/utils'; import { ChangeValidator } from '../externalApiSync'; export class DhisChangeValidator extends ChangeValidator { - queryValidSurveyResponseIds = async (surveyResponseIds, excludeEventBased = false) => { + queryValidSurveyResponseIds = async (surveyResponseIds, settings = {}) => { + const { excludeEventBased = false, outdated = false } = settings; const nonPublicDemoLandUsers = ( await this.models.database.executeSql( ` @@ -20,10 +21,12 @@ export class DhisChangeValidator extends ChangeValidator { `, ) ).map(r => r.user_id); + const validSurveyResponseIds = []; const batchSize = this.models.database.maxBindingsPerQuery - nonPublicDemoLandUsers.length; for (let i = 0; i < surveyResponseIds.length; i += batchSize) { const batchOfSurveyResponseIds = surveyResponseIds.slice(i, i + batchSize); + const batchOfSurveyResponses = await this.models.database.executeSql( ` SELECT DISTINCT survey_response.id as id @@ -41,31 +44,129 @@ export class DhisChangeValidator extends ChangeValidator { ? `AND ${this.models.surveyResponse.getExcludeEventsQueryClause()}` : '' } + AND survey_response.outdated = ? AND survey_response.id IN (${batchOfSurveyResponseIds.map(() => '?').join(',')}); `, - [...nonPublicDemoLandUsers, ...batchOfSurveyResponseIds], + [...nonPublicDemoLandUsers, outdated, ...batchOfSurveyResponseIds], ); + validSurveyResponseIds.push(...batchOfSurveyResponses.map(r => r.id)); } return validSurveyResponseIds; }; + getAssociatedAnswers = async (surveyResponseIds, allChanges) => { + const answerChanges = allChanges.filter(c => c.record_type === 'answer'); + // get the answers associated with the survey responses + const associatedAnswers = await this.models.answer.find({ + survey_response_id: surveyResponseIds, + }); + + const filteredAnswers = associatedAnswers.filter(a => { + const change = answerChanges.find(c => c.record_id === a.id); + if (change) { + return false; + } + return true; + }); + + if (filteredAnswers.length === 0) return []; + + // filter out answers for questions that do not sync to dhis + const dhisLinkedAnswers = await this.filterDhisLinkedAnswers(filteredAnswers); + + return dhisLinkedAnswers; + }; + + getDeletesForAssociatedAnswers = async (surveyResponseIds, allChanges) => { + const answersToChange = await this.getAssociatedAnswers(surveyResponseIds, allChanges); + + // create delete changes for the answers + const outdatedAnswerDeletes = await Promise.all( + answersToChange.map(async a => ({ + record_type: 'answer', + record_id: a.id, + type: 'delete', + new_record: null, + old_record: await a.getData(), + })), + ); + + return outdatedAnswerDeletes; + }; + + getOutdatedAnswersAndSurveyResponses = async changes => { + // get the survey response changes that are already outdated + const surveyResponseChangesThatAreAlreadyOutdated = changes + .filter( + c => + c.record_type === 'survey_response' && + c.new_record && + c.new_record.outdated === true && + c.old_record && + c.old_record.outdated === true, + ) + .map(c => c.record_id); + // get the survey response ids that are being updated to 'outdated' + const surveyResponseUpdateIds = await this.getValidSurveyResponseUpdates(changes, { + excludeEventBased: false, + outdated: true, + }); + + // ignore survey response changes that are already outdated + const validSurveyResponseIds = surveyResponseUpdateIds.filter( + id => !surveyResponseChangesThatAreAlreadyOutdated.includes(id), + ); + + if (validSurveyResponseIds.length === 0) return []; + + // get the survey response changes that are being updated to 'outdated' + const surveyResponseChanges = this.filterChangesWithMatchingIds( + changes, + validSurveyResponseIds, + ); + + if (surveyResponseChanges.length === 0) return []; + + const outdatedSurveyResponseDeletes = surveyResponseChanges.map(s => ({ + ...s, + type: 'delete', + new_record: null, + })); + + // get the answers that are associated with the survey responses that are being updated to 'outdated' + const deleteAnswers = await this.getDeletesForAssociatedAnswers( + surveyResponseUpdateIds, + changes, + ); + + return [...outdatedSurveyResponseDeletes, ...deleteAnswers]; + }; + getValidDeletes = async changes => { - return this.getPreviouslySyncedDeletes( + const outdatedSurveyResponseDeletes = await this.getOutdatedAnswersAndSurveyResponses(changes); + + const previouslySyncedDeletes = await this.getPreviouslySyncedDeletes( changes, this.models.dhisSyncQueue, this.models.dhisSyncLog, ); + + return [...outdatedSurveyResponseDeletes, ...previouslySyncedDeletes]; }; getValidAnswerUpdates = async updateChanges => { const answers = this.getRecordsFromChangesForModel(updateChanges, this.models.answer); + if (answers.length === 0) return []; const surveyResponseIds = getUniqueEntries(answers.map(a => a.survey_response_id)); // check which survey responses are valid const validSurveyResponseIds = new Set( - await this.queryValidSurveyResponseIds(surveyResponseIds, true), + await this.queryValidSurveyResponseIds(surveyResponseIds, { + excludeEventBased: true, + outdated: false, + }), ); // filter out answers for questions that do not sync to dhis @@ -101,13 +202,14 @@ export class DhisChangeValidator extends ChangeValidator { return filteredAnswers; }; - getValidSurveyResponseUpdates = async updateChanges => { + getValidSurveyResponseUpdates = async (updateChanges, settings = {}) => { const surveyResponseIds = this.getIdsFromChangesForModel( updateChanges, this.models.surveyResponse, ); + if (surveyResponseIds.length === 0) return []; - return this.queryValidSurveyResponseIds(surveyResponseIds); + return this.queryValidSurveyResponseIds(surveyResponseIds, settings); }; getValidEntityUpdates = async updateChanges => { @@ -118,15 +220,48 @@ export class DhisChangeValidator extends ChangeValidator { return entities.filter(e => e.allowsPushingToDhis()).map(e => e.id); }; + // get associated answers for the survey responses that are being reinstated + getAnswersToUpdate = async allChanges => { + const surveyResponseChanges = await allChanges.filter( + c => + c.record_type === 'survey_response' && + c.old_record && + c.old_record.outdated === true && + c.new_record && + c.new_record.outdated === false, + ); + + const surveyResponseIdsToUpdate = surveyResponseChanges.map(c => c.record_id); + + const answerChanges = await this.getAssociatedAnswers(surveyResponseIdsToUpdate, allChanges); + return Promise.all( + answerChanges.map(async a => { + const data = await a.getData(); + return { + record_type: 'answer', + record_id: a.id, + type: 'update', + new_record: data, + old_record: data, + }; + }), + ); + }; + getValidUpdates = async changes => { const updateChanges = this.getUpdateChanges(changes); const validEntityIds = await this.getValidEntityUpdates(updateChanges); const validAnswerIds = await this.getValidAnswerUpdates(updateChanges); const validSurveyResponseIds = await this.getValidSurveyResponseUpdates(updateChanges); - return this.filterChangesWithMatchingIds(changes, [ - ...validEntityIds, - ...validAnswerIds, - ...validSurveyResponseIds, - ]); + const answersToUpdate = await this.getAnswersToUpdate(changes); + return [ + ...this.filterChangesWithMatchingIds(changes, [ + ...validEntityIds, + ...validAnswerIds, + ...validSurveyResponseIds, + ...answersToUpdate, + ]), + ...answersToUpdate, + ]; }; } diff --git a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js index 48185a9d7f..dc7cf1c8d0 100644 --- a/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js +++ b/packages/central-server/src/tests/apiV2/export/exportSurveyResponses/exportSurveyResponses.test.js @@ -116,6 +116,7 @@ describe('exportSurveyResponses(): GET export/surveysResponses', () => { { surveyCode: survey2.code, entityCode: kiribatiCountry.code, + outdated: true, data_time: new Date(), answers: { question_7_test: 'question_7_test answer', @@ -161,6 +162,16 @@ describe('exportSurveyResponses(): GET export/surveysResponses', () => { expectAccessibleExportDataHeaderRow(exportData); } }); + + it('ignores outdated survey responses', async () => { + await app.grantAccess(DEFAULT_POLICY); + await app.get( + `export/surveyResponses?surveyCodes=${survey2.code}&countryCode=${kiribatiCountry.code}`, + ); + + // Should not fetch outdated survey response so there should be no export data + expect(xlsx.utils.aoa_to_sheet).not.to.have.been.called; + }); }); describe('Should not allow exporting a survey if users do not have Tupaia Admin Panel and survey permission group access to the corresponding countries', () => { diff --git a/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js b/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js index f2f95e5fad..22c4f5ab4f 100644 --- a/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js +++ b/packages/central-server/src/tests/apiV2/export/exportSurveys/cellBuilders/EntityConfigCellBuilder.test.js @@ -30,6 +30,12 @@ const QUESTIONS = [ code: 'entity_question', type: ANSWER_TYPES.ENTITY, }, + { + id: 'q5', + code: 'question_5_code', + validationCriteria: 'mandatory: true', + type: ANSWER_TYPES.CODE_GENERATOR, + }, ]; const assertCanProcessAndBuildEntity = config => @@ -58,7 +64,7 @@ describe('EntityConfigCellBuilder', () => { it('supports validating presence of fields.name, fields.code AND fields.type when createNew is true', async () => { await assertCanProcessAndBuildEntity( - 'createNew: Yes\r\nfields.name: question_3_code\r\nfields.code: question_3_code\r\nfields.type: school', + 'createNew: Yes\r\nfields.name: question_3_code\r\nfields.code: question_5_code\r\nfields.type: school', ); }); diff --git a/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js b/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js index cd6e72dac5..78fbeaec49 100644 --- a/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js +++ b/packages/central-server/src/tests/apiV2/resubmitSurveyResponse.test.js @@ -50,7 +50,7 @@ describe('resubmit surveyResponse endpoint', () => { app.revokeAccess(); }); - it('Should throw error when answers contain an invalid question code', async () => { + it('Should return a successful response, and outdate the original survey response', async () => { const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ { surveyCode: 'TEST_SURVEY_RESP_CRUD', @@ -60,124 +60,25 @@ describe('resubmit surveyResponse endpoint', () => { }, ]); - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - data_time: '2020-02-02T09:00:00', - answers: { - TEST_INV: '1236', - }, - }, - }); - - expect(response.body).to.have.keys('error'); - }); - - it('Should return a successful response', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ + const response = await app.post( + `surveyResponses/${surveyResponse.surveyResponse.id}/resubmit`, { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - data_time: '2020-01-31T09:00:00', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - data_time: '2020-02-02T09:00:00', - answers: { - [questionCode(1)]: '1236', + body: { + data_time: '2020-02-02T09:00:00', + survey_id: surveyId, + entity_code: ENTITY_ID, + timezone: 'Pacific/Auckland', + start_time: '2020-02-02T09:00:00', + end_time: '2020-02-02T09:10:00', + answers: { + [questionCode(1)]: '1236', + }, }, }, - }); - - expectSuccess(response); - }); - - it('Should update the survey response fields', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - assessor_name: 'Bob', - }, - }); - - expectSuccess(response); - const updatedSurveyResponse = await app.get( - `surveyResponses/${surveyResponse.surveyResponse.id}`, ); - expect(updatedSurveyResponse.body.assessor_name).to.equal('Bob'); - }); - - it('Should update an existing answer', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(1)]: 'update test' }, - }, - }); - - expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body[0].text).to.equal('update test'); - }); - - it('Should create a new answer if doesnt exist', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(2)]: 'hello world' }, - }, - }); - - expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body).to.have.length(2); - expect(body.filter(answer => answer.text === 'hello world')).to.not.be.an('undefined'); - }); - - it('Should delete an answer if it already exists and the update is null', async () => { - const [surveyResponse] = await buildAndInsertSurveyResponses(models, [ - { - surveyCode: 'TEST_SURVEY_RESP_CRUD', - entityCode: ENTITY_ID, - assessor_name: 'Jane', - answers: { [questionCode(1)]: '123' }, - }, - ]); - - const response = await app.post(`surveyResponse/${surveyResponse.surveyResponse.id}/resubmit`, { - body: { - answers: { [questionCode(1)]: null }, - }, - }); expectSuccess(response); - const { body } = await app.get(`surveyResponses/${surveyResponse.surveyResponse.id}/answers`); - expect(body).to.have.length(0); + const originalResponse = await models.surveyResponse.findById(surveyResponse.surveyResponse.id); + expect(originalResponse.outdated).to.equal(true); }); }); diff --git a/packages/central-server/src/tests/apiV2/surveyResponse.test.js b/packages/central-server/src/tests/apiV2/surveyResponse.test.js index 74654e9607..1371db9192 100644 --- a/packages/central-server/src/tests/apiV2/surveyResponse.test.js +++ b/packages/central-server/src/tests/apiV2/surveyResponse.test.js @@ -510,7 +510,7 @@ describe('surveyResponse endpoint', () => { numberOfAnswersInSurveyResponse = await models.answer.count({ survey_response_id: surveyResponseId, }); - response = await app.post(`surveyResponse/${surveyResponseId}/resubmit`, { + response = await app.put(`surveyResponses/${surveyResponseId}`, { body: { entity_id: newEntityId, }, diff --git a/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js b/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js new file mode 100644 index 0000000000..1bb1d65e9c --- /dev/null +++ b/packages/central-server/src/tests/dhis/DhisChangeValidator.test.js @@ -0,0 +1,215 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { expect } from 'chai'; +import { + buildAndInsertSurvey, + buildAndInsertSurveyResponses, + findOrCreateDummyCountryEntity, + findOrCreateDummyRecord, + generateId, +} from '@tupaia/database'; +import { getModels, TestableApp } from '../testUtilities'; +import { DhisChangeValidator } from '../../dhis/DhisChangeValidator'; + +const models = getModels(); +describe('DhisChangeValidator', async () => { + const ChangeValidator = new DhisChangeValidator(models); + + let answers; + let surveyResponse; + + before(async () => { + const user = await findOrCreateDummyRecord(models.user, { email: 'test_user@email.com' }); + const { entity: tongaEntity } = await findOrCreateDummyCountryEntity(models, { code: 'TO' }); + const adminPermissionGroup = await findOrCreateDummyRecord(models.permissionGroup, { + name: 'Admin', + }); + const SURVEY = { + code: 'testSurvey', + name: 'Test Survey', + dataGroup: { + code: 'testDataGroup', + service_type: 'dhis', + config: { + dhisInstanceCode: 'test_instance', + }, + }, + questions: [ + { + code: 'question1', + type: 'FreeText', + prompt: 'Question 1', + }, + ], + }; + + await buildAndInsertSurvey(models, SURVEY); + const SURVEY_RESPONSE = { + surveyCode: 'testSurvey', + entityCode: tongaEntity.code, + answers: { + question1: 'answer1', + }, + }; + + await models.userEntityPermission.create({ + user_id: user.id, + entity_id: tongaEntity.id, + permission_group_id: adminPermissionGroup.id, + }); + + const [builtSurveyResponse] = await buildAndInsertSurveyResponses( + models, + [SURVEY_RESPONSE], + user, + ); + answers = await Promise.all(builtSurveyResponse.answers.map(answer => answer.getData())); + surveyResponse = await builtSurveyResponse.surveyResponse.getData(); + }); + + describe('getOutdatedAnswersAndSurveyResponses', () => { + it('should return outdated survey responses and associated answers as deletes', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + }, + old_record: surveyResponse, + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + expect(result.length).to.equal(2); + expect(result).to.deep.equal([ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'delete', + new_record: null, + old_record: surveyResponse, + }, + ...answers.map(answer => ({ + record_id: answer.id, + record_type: 'answer', + type: 'delete', + new_record: null, + old_record: answer, + })), + ]); + }); + + it('should not include any answers already being deleted in the changes list', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + }, + old_record: surveyResponse, + }, + { + record_id: answers[0].id, + record_type: 'answer', + type: 'delete', + old_record: answers[0], + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + expect(result.length).to.equal(1); + expect(result).to.deep.equal([ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'delete', + new_record: null, + old_record: surveyResponse, + }, + ]); + }); + + it('should not include any already outdated survey responses being updated', async () => { + await models.surveyResponse.updateById(surveyResponse.id, { outdated: true }); + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: { + ...surveyResponse, + outdated: true, + data_time: new Date('2024-07-24').toISOString(), + }, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + ]; + const result = await ChangeValidator.getOutdatedAnswersAndSurveyResponses(changes); + + expect(result.length).to.equal(0); + }); + }); + + describe('getAnswersToUpdate', () => { + it('should return answers associated with any survey responses that are changing from outdated to not outdated', async () => { + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: surveyResponse, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + ]; + const result = await ChangeValidator.getAnswersToUpdate(changes); + expect(result.length).to.equal(1); + expect(result).to.deep.equal( + answers.map(answer => ({ + record_id: answer.id, + record_type: 'answer', + type: 'update', + new_record: answer, + old_record: answer, + })), + ); + }); + + it('should not include any answers already being updated in the changes list', async () => { + const changes = [ + { + record_id: surveyResponse.id, + record_type: 'survey_response', + type: 'update', + new_record: surveyResponse, + old_record: { + ...surveyResponse, + outdated: true, + }, + }, + { + record_id: answers[0].id, + record_type: 'answer', + type: 'update', + old_record: answers[0], + }, + ]; + const result = await ChangeValidator.getAnswersToUpdate(changes); + expect(result.length).to.equal(0); + }); + }); +}); diff --git a/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js b/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js index bc179dc661..3f12dfeea6 100644 --- a/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js +++ b/packages/database/src/testUtilities/buildAndInsertSurveyResponses.js @@ -65,8 +65,8 @@ const buildAndInsertSurveyResponse = async ( * ]); * ``` */ -export const buildAndInsertSurveyResponses = async (models, surveyResponses) => { - const user = await upsertDummyRecord(models.user); +export const buildAndInsertSurveyResponses = async (models, surveyResponses, userData = {}) => { + const user = await upsertDummyRecord(models.user, userData); const builtResponses = []; for (let i = 0; i < surveyResponses.length; i++) { const surveyResponse = surveyResponses[i]; diff --git a/packages/datatrak-web-server/package.json b/packages/datatrak-web-server/package.json index 043f3eb21f..c5e62eb60a 100644 --- a/packages/datatrak-web-server/package.json +++ b/packages/datatrak-web-server/package.json @@ -33,6 +33,8 @@ "@tupaia/types": "workspace:*", "@tupaia/utils": "workspace:*", "camelcase-keys": "^6.2.2", + "date-fns": "^2.29.2", + "date-fns-tz": "^2.0.1", "express": "^4.19.2", "lodash.groupby": "^4.6.0", "lodash.keyby": "^4.6.0", diff --git a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts index 0ef34a55b8..f69bdc3e4b 100644 --- a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts +++ b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts @@ -50,7 +50,7 @@ describe('processSurveyResponse', () => { surveyId: 'theSurveyId', countryId: 'theCountryId', startTime: 'theStartTime', - timezone: 'theTimezone', + timezone: 'Pacific/Auckland', }; const processedResponseData = { @@ -61,7 +61,7 @@ describe('processSurveyResponse', () => { entity_id: 'theCountryId', end_time: timestamp, timestamp: timestamp, - timezone: 'theTimezone', + timezone: 'Pacific/Auckland', options_created: [], entities_upserted: [], qr_codes_to_create: [], @@ -148,13 +148,13 @@ describe('processSurveyResponse', () => { }, ], answers: { - question1: '2022-01-01', + question1: '2022-01-01T00:00', }, }); expect(result).toEqual({ ...processedResponseData, - data_time: new Date('2022-01-01').toISOString(), + data_time: '2022-01-01T00:00+13:00', answers: [], }); }); @@ -172,13 +172,13 @@ describe('processSurveyResponse', () => { }, ], answers: { - question1: '2022-01-01', + question1: '2022-01-01T00:00', }, }); expect(result).toEqual({ ...processedResponseData, - data_time: new Date('2022-01-01').toISOString(), + data_time: '2022-01-01T00:00+13:00', answers: [], }); }); @@ -544,7 +544,7 @@ describe('processSurveyResponse', () => { ], answers: { question1: { - value: 'theEncodedFile', + value: 'data://theEncodedFile', name: 'theFileName', }, }, @@ -557,11 +557,101 @@ describe('processSurveyResponse', () => { question_id: 'question1', type: QuestionType.File, body: { - data: 'theEncodedFile', + data: 'data://theEncodedFile', uniqueFileName: getUniqueSurveyQuestionFileName('theFileName'), }, }, ], }); }); + + it('should handle when question type is File and the file is not an encoded file', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.File, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: { + value: 'filename.png', + name: 'theFileName', + }, + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.File, + body: 'filename.png', + }, + ], + }); + }); + + it('should add the timezone offset when question type is Date', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.Date, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: '2022-01-01T00:00', + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.Date, + body: '2022-01-01T00:00+13:00', + }, + ], + }); + }); + + it('should add the timezone offset when question type is DateTime', async () => { + const result = await processSurveyResponse(mockModels, { + ...responseData, + questions: [ + { + questionId: 'question1', + type: QuestionType.DateTime, + componentNumber: 1, + text: 'question1', + screenId: 'screen1', + }, + ], + answers: { + question1: '2022-01-01T00:00', + }, + }); + + expect(result).toEqual({ + ...processedResponseData, + answers: [ + { + question_id: 'question1', + type: QuestionType.DateTime, + body: '2022-01-01T00:00+13:00', + }, + ], + }); + }); }); diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index fc9ff4d249..e25b76bb3a 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -45,6 +45,8 @@ import { EntitiesRequest, GenerateLoginTokenRoute, GenerateLoginTokenRequest, + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, } from '../routes'; import { attachAccessPolicy } from './middleware'; @@ -65,6 +67,10 @@ export async function createApp() { 'submitSurveyResponse', handleWith(SubmitSurveyResponseRoute), ) + .post( + 'resubmitSurveyResponse/:originalSurveyResponseId', + handleWith(ResubmitSurveyResponseRoute), + ) .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) .get('getUser', handleWith(UserRoute)) .get('entity/:entityCode', handleWith(SingleEntityRoute)) diff --git a/packages/datatrak-web-server/src/constants.ts b/packages/datatrak-web-server/src/constants.ts new file mode 100644 index 0000000000..fdc1e10866 --- /dev/null +++ b/packages/datatrak-web-server/src/constants.ts @@ -0,0 +1,5 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +export const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; diff --git a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts index a7dcacbb1f..53e273853c 100644 --- a/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts +++ b/packages/datatrak-web-server/src/routes/SingleSurveyResponseRoute.ts @@ -7,6 +7,9 @@ import { Request } from 'express'; import camelcaseKeys from 'camelcase-keys'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { AccessPolicy } from '@tupaia/access-policy'; +import { TUPAIA_ADMIN_PANEL_PERMISSION_GROUP } from '../constants'; +import { PermissionsError } from '@tupaia/utils'; export type SingleSurveyResponseRequest = Request< DatatrakWebSingleSurveyResponseRequest.Params, @@ -22,25 +25,82 @@ const DEFAULT_FIELDS = [ 'country.name', 'data_time', 'entity.name', + 'entity.id', 'id', 'survey.name', 'survey.code', + 'user_id', + 'country.code', + 'survey.permission_group_id', + 'timezone', ]; +const BES_ADMIN_PERMISSION_GROUP = 'BES Admin'; + +// If the user is not a BES admin or does not have access to the admin panel, they should not be able to view the survey response +const assertCanViewSurveyResponse = ( + accessPolicy: AccessPolicy, + countryCode: string, + surveyPermissionGroupName: string, +) => { + const isBESAdmin = accessPolicy.allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP); + if (isBESAdmin) { + return true; + } + + const hasAdminPanelAccess = accessPolicy.allowsSome( + undefined, + TUPAIA_ADMIN_PANEL_PERMISSION_GROUP, + ); + + const hasAccessToCountry = accessPolicy.allows(countryCode, surveyPermissionGroupName); + // The user must have access to the admin panel AND the country with the survey permission group + if (!hasAdminPanelAccess && !hasAccessToCountry) { + throw new PermissionsError('You do not have access to view this survey response'); + } + + return true; +}; + export class SingleSurveyResponseRoute extends Route { public async buildResponse() { - const { ctx, params, query } = this.req; + const { ctx, params, query, accessPolicy, models } = this.req; const { id: responseId } = params; const { fields = DEFAULT_FIELDS } = query; + const currentUser = await ctx.services.central.getUser(); + const { id } = currentUser; + const surveyResponse = await ctx.services.central.fetchResources( `surveyResponses/${responseId}`, { columns: fields }, ); + + if (!surveyResponse) { + throw new Error(`Survey response with id ${responseId} not found`); + } + + const { + user_id: userId, + 'country.code': countryCode, + 'survey.permission_group_id': surveyPermissionGroupId, + ...response + } = surveyResponse; + + // If the user is not the owner of the survey response, they should not be able to view the survey response unless they are a BES admin or have access to the admin panel + if (userId !== id) { + const permissionGroup = await models.permissionGroup.findById(surveyPermissionGroupId); + if (!permissionGroup) { + throw new Error('Permission group for survey not found'); + } + assertCanViewSurveyResponse(accessPolicy, countryCode, permissionGroup.name); + } + const answerList = await ctx.services.central.fetchResources('answers', { filter: { survey_response_id: surveyResponse.id }, columns: ANSWER_COLUMNS, + pageSize: 'ALL', }); const answers = answerList.reduce( (output: Record, answer: { question_id: string; text: string }) => ({ @@ -51,6 +111,6 @@ export class SingleSurveyResponseRoute extends Route; + +export class ResubmitSurveyResponseRoute extends Route { + public async buildResponse() { + const surveyResponseData = this.req.body; + const { central: centralApi } = this.req.ctx.services; + const { session, models, params } = this.req; + const { originalSurveyResponseId } = params; + + const { qr_codes_to_create, recent_entities, ...processedResponse } = + await processSurveyResponse(models, surveyResponseData); + + await centralApi.resubmitSurveyResponse(originalSurveyResponseId, processedResponse); + + // If the user is logged in, add the entities they answered to their recent entities list + if (!!session && processedResponse.user_id) { + const { user_id: userId } = processedResponse; + // add these after the survey response has been submitted because we want to be able to add newly created entities to the recent entities list + await addRecentEntities(models, userId, recent_entities); + } + + return { + qrCodeEntitiesCreated: qr_codes_to_create || [], + }; + } +} diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts index a4dfe5d7a1..7be4fa0082 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/index.ts @@ -7,3 +7,7 @@ export { SubmitSurveyResponseRequest, SubmitSurveyResponseRoute, } from './SubmitSurveyResponseRoute'; +export { + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, +} from './ResubmitSurveyResponseRoute'; diff --git a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts index 27ad68033b..f8da4959ca 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurveyReponse/processSurveyResponse.ts @@ -2,8 +2,13 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { + getUniqueSurveyQuestionFileName, + formatDateInTimezone, + getOffsetForTimezone, +} from '@tupaia/utils'; +import { + DatatrakWebResubmitSurveyResponseRequest, DatatrakWebSubmitSurveyResponseRequest, Entity, MeditrakSurveyResponseRequest, @@ -13,7 +18,9 @@ import { import { DatatrakWebServerModelRegistry } from '../../types'; import { buildUpsertEntity } from './buildUpsertEntity'; -type SurveyRequestT = DatatrakWebSubmitSurveyResponseRequest.ReqBody; +type SurveyRequestT = + | DatatrakWebSubmitSurveyResponseRequest.ReqBody + | DatatrakWebResubmitSurveyResponseRequest.ReqBody; type CentralServerSurveyResponseT = MeditrakSurveyResponseRequest & { qr_codes_to_create?: Entity[]; recent_entities: string[]; @@ -31,6 +38,11 @@ export const isUpsertEntityQuestion = (config?: SurveyScreenComponentConfig) => return config.entity.fields && Object.keys(config.entity.fields).length > 0; }; +const addTimezoneToDateString = (dateString: string, timezone: string) => { + const timezoneOffset = getOffsetForTimezone(timezone, new Date(dateString)); + return `${dateString}${timezoneOffset}`; +}; + // Process the survey response data into the format expected by the endpoint export const processSurveyResponse = async ( models: DatatrakWebServerModelRegistry, @@ -44,6 +56,7 @@ export const processSurveyResponse = async ( startTime, userId, timezone, + dataTime, } = surveyResponseData; const today = new Date(); @@ -55,7 +68,7 @@ export const processSurveyResponse = async ( start_time: startTime, entity_id: countryId, end_time: timestamp, - data_time: timestamp, + data_time: dataTime ? addTimezoneToDateString(dataTime, timezone) : timestamp, timestamp, timezone, entities_upserted: [], @@ -64,6 +77,10 @@ export const processSurveyResponse = async ( options_created: [], answers: [], }; + // if there is an entityId in the survey response data, add it to the survey response. This will happen in cases of resubmission + if ('entityId' in surveyResponseData && surveyResponseData.entityId) { + surveyResponse.entity_id = surveyResponseData.entityId; + } // Process answers and save the response in the database const answersToSubmit = [] as Record[]; @@ -113,11 +130,22 @@ export const processSurveyResponse = async ( // Handle special question types switch (type) { - // format dates to be ISO strings + // Add the timezone offset to the date string so it saves in the correct timezone case QuestionType.SubmissionDate: case QuestionType.DateOfData: { - const date = new Date(answer as string); - surveyResponse.data_time = date.toISOString(); + surveyResponse.data_time = addTimezoneToDateString(answer as string, timezone); + break; + } + + case QuestionType.Date: + case QuestionType.DateTime: { + // Add the timezone offset to the date string so it saves in the correct timezone + if (answer) { + answersToSubmit.push({ + ...answerObject, + body: addTimezoneToDateString(answer as string, timezone), + }); + } break; } @@ -129,6 +157,15 @@ export const processSurveyResponse = async ( } case QuestionType.File: { const { name, value } = answer as FileUploadAnswerT; + const isBase64 = value.startsWith('data:'); + // if the file is not base64 encoded, save the file as it is, as this means it's a file that was uploaded already, and this is a resubmission + if (!isBase64) { + answersToSubmit.push({ + ...answerObject, + body: value, + }); + break; + } answersToSubmit.push({ ...answerObject, body: { diff --git a/packages/datatrak-web-server/src/routes/UserRoute.ts b/packages/datatrak-web-server/src/routes/UserRoute.ts index 42803ca0ce..1b87da83fd 100644 --- a/packages/datatrak-web-server/src/routes/UserRoute.ts +++ b/packages/datatrak-web-server/src/routes/UserRoute.ts @@ -6,6 +6,7 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import { DatatrakWebUserRequest, WebServerProjectRequest } from '@tupaia/types'; +import { TUPAIA_ADMIN_PANEL_PERMISSION_GROUP } from '../constants'; export type UserRequest = Request< DatatrakWebUserRequest.Params, @@ -14,8 +15,6 @@ export type UserRequest = Request< DatatrakWebUserRequest.ReqQuery >; -const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel'; - export class UserRoute extends Route { public async buildResponse() { const { ctx, session, accessPolicy } = this.req; diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 01be0e48d2..efad4a0641 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -11,7 +11,12 @@ export { ProjectsRequest, ProjectsRoute } from './ProjectsRoute'; export { SingleEntityRequest, SingleEntityRoute } from './SingleEntityRoute'; export { EntityDescendantsRequest, EntityDescendantsRoute } from './EntityDescendantsRoute'; export { ProjectRequest, ProjectRoute } from './ProjectRoute'; -export { SubmitSurveyResponseRequest, SubmitSurveyResponseRoute } from './SubmitSurveyReponse'; +export { + SubmitSurveyResponseRequest, + SubmitSurveyResponseRoute, + ResubmitSurveyResponseRequest, + ResubmitSurveyResponseRoute, +} from './SubmitSurveyReponse'; export { RecentSurveysRequest, RecentSurveysRoute } from './RecentSurveysRoute'; export { SingleSurveyResponseRequest, diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index 82fb358276..6c974ba1a6 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -9,6 +9,7 @@ import { FeedItemModel, OneTimeLoginModel, OptionModel, + PermissionGroupModel, SurveyModel, SurveyResponseModel, UserModel, @@ -23,4 +24,5 @@ export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly surveyResponse: SurveyResponseModel; readonly oneTimeLogin: OneTimeLoginModel; readonly option: OptionModel; + readonly permissionGroup: PermissionGroupModel; } diff --git a/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json b/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json index 42a662a4b1..9506ae8e30 100644 --- a/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json +++ b/packages/datatrak-web/src/__tests__/mocks/mockData/survey.json @@ -47,8 +47,10 @@ "entity": { "type": ["sub_district"], "createNew": false, - "parentId": { - "questionId": "603597d461f76a42af02a366" + "filter": { + "parentId": { + "questionId": "603597d461f76a42af02a366" + } } } }, @@ -73,11 +75,13 @@ "entity": { "type": ["facility"], "createNew": false, - "parentId": { - "questionId": "" - }, - "grandparentId": { - "questionId": "603597d461f76a42af02a366" + "filter": { + "parentId": { + "questionId": "" + }, + "grandparentId": { + "questionId": "603597d461f76a42af02a366" + } } } }, diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index 7bd41ae67c..4825767561 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -16,3 +16,4 @@ export { useRequestDeleteAccount } from './useRequestDeleteAccount'; export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; +export { useResubmitSurveyResponse } from './useResubmitSurveyResponse'; diff --git a/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts new file mode 100644 index 0000000000..c03060a4e5 --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useResubmitSurveyResponse.ts @@ -0,0 +1,49 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation } from 'react-query'; +import { generatePath, useNavigate, useParams } from 'react-router'; +import { post } from '../api'; +import { useSurveyResponse } from '../queries'; +import { useSurveyForm } from '../../features'; +import { ROUTES } from '../../constants'; +import { AnswersT, useSurveyResponseData } from './useSubmitSurveyResponse'; + +export const useResubmitSurveyResponse = () => { + const navigate = useNavigate(); + const params = useParams(); + const { surveyResponseId } = params; + + const { resetForm } = useSurveyForm(); + + const surveyResponseData = useSurveyResponseData(); + const { data: surveyResponse } = useSurveyResponse(surveyResponseId); + + return useMutation( + async (answers: AnswersT) => { + if (!answers) { + return; + } + + return post(`resubmitSurveyResponse/${surveyResponseId}`, { + data: { + ...surveyResponseData, + answers, + // keep the same dataTime and userId as the original survey response + dataTime: surveyResponse?.dataTime ? surveyResponse?.dataTime : new Date(), + userId: surveyResponse?.userId, + entityId: surveyResponse?.entityId, + timezone: surveyResponse?.timezone, + }, + }); + }, + { + onSuccess: () => { + resetForm(); + navigate(generatePath(ROUTES.SURVEY_RESUBMIT_SUCCESS, params)); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts index 601d39b552..6f0fd29f4f 100644 --- a/packages/datatrak-web/src/api/queries/useSurveyResponse.ts +++ b/packages/datatrak-web/src/api/queries/useSurveyResponse.ts @@ -1,16 +1,84 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { useQuery } from 'react-query'; -import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types'; +import { useNavigate } from 'react-router'; +import { DatatrakWebSingleSurveyResponseRequest, QuestionType } from '@tupaia/types'; import { get } from '../api'; +import { ROUTES } from '../../constants'; +import { errorToast } from '../../utils'; +import { getAllSurveyComponents, useSurveyForm } from '../../features'; +import { stripTimezoneFromDate } from '@tupaia/utils'; export const useSurveyResponse = (surveyResponseId?: string) => { + const { setFormData, surveyScreens } = useSurveyForm(); + const navigate = useNavigate(); + + const flattenedScreenComponents = getAllSurveyComponents(surveyScreens); + return useQuery( ['surveyResponse', surveyResponseId], (): Promise => get(`surveyResponse/${surveyResponseId}`), - { enabled: !!surveyResponseId }, + { + enabled: !!surveyResponseId, + meta: { + applyCustomErrorHandling: true, + }, + onError(error: any) { + if (error.code === 403) + return navigate(ROUTES.NOT_AUTHORISED, { state: { errorMessage: error.message } }); + errorToast(error.message); + }, + onSuccess: data => { + const primaryEntityQuestion = flattenedScreenComponents.find( + component => component.type === QuestionType.PrimaryEntity, + ); + + const dateOfDataQuestion = flattenedScreenComponents.find( + component => + component.type === QuestionType.DateOfData || + component.type === QuestionType.SubmissionDate, + ); + // handle updating answers here - if this is done in the component, the answers get reset on every re-render + const formattedAnswers = Object.entries(data.answers).reduce((acc, [key, value]) => { + // If the value is a stringified object, parse it + const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); + const question = flattenedScreenComponents.find( + component => component.questionId === key, + ); + if (!question) return acc; + if (question.type === QuestionType.File && value) { + // If the value is a file, split the value to get the file name + const withoutPrefix = value.split('files/'); + const fileNameParts = withoutPrefix[withoutPrefix.length - 1].split('_'); + // remove first element of the array as it is the file id + const fileName = fileNameParts.slice(1).join('_'); + return { ...acc, [key]: { name: fileName, value } }; + } + + if ( + (question.type === QuestionType.Date || question.type === QuestionType.DateTime) && + value + ) { + // strip timezone from date so that it gets displayed the same no matter the user's timezone + return { ...acc, [key]: stripTimezoneFromDate(value) }; + } + + return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; + }, {}); + + if (primaryEntityQuestion && data.entityId) { + formattedAnswers[primaryEntityQuestion.questionId] = data.entityId; + } + + if (dateOfDataQuestion && data.dataTime) { + formattedAnswers[dateOfDataQuestion.questionId] = data.dataTime; + } + + setFormData(formattedAnswers); + }, + }, ); }; diff --git a/packages/datatrak-web/src/components/ErrorDisplay.tsx b/packages/datatrak-web/src/components/ErrorDisplay.tsx index 6541405894..23106121f7 100644 --- a/packages/datatrak-web/src/components/ErrorDisplay.tsx +++ b/packages/datatrak-web/src/components/ErrorDisplay.tsx @@ -33,11 +33,11 @@ const Container = styled(Paper).attrs({ `; export const ErrorDisplay = ({ - error, + errorMessage, children, title, }: { - error?: Error | null; + errorMessage?: string | null; children?: ReactNode; title: ReactNode; }) => { @@ -45,7 +45,7 @@ export const ErrorDisplay = ({ {title} - {error && {error.message}} + {errorMessage && {errorMessage}} {children} diff --git a/packages/datatrak-web/src/constants/url.ts b/packages/datatrak-web/src/constants/url.ts index 02432680f8..21873ebd5e 100644 --- a/packages/datatrak-web/src/constants/url.ts +++ b/packages/datatrak-web/src/constants/url.ts @@ -1,10 +1,12 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ const SURVEY_URL = '/survey/:countryCode/:surveyCode'; +const SURVEY_RESUBMIT_BASE_URL = `${SURVEY_URL}/resubmit/:surveyResponseId`; + export const ROUTES = { HOME: '/', LOGIN: '/login', @@ -19,13 +21,24 @@ export const ROUTES = { SURVEY_SUCCESS: `${SURVEY_URL}/success`, SURVEY_REVIEW: `${SURVEY_URL}/review`, SURVEY_RESPONSE: `${SURVEY_URL}/response/:surveyResponseId`, + SURVEY_RESUBMIT: SURVEY_RESUBMIT_BASE_URL, + SURVEY_RESUBMIT_SCREEN: `${SURVEY_RESUBMIT_BASE_URL}/screen/:screenNumber`, + SURVEY_RESUBMIT_REVIEW: `${SURVEY_RESUBMIT_BASE_URL}/review`, + SURVEY_RESUBMIT_SUCCESS: `${SURVEY_RESUBMIT_BASE_URL}/success`, ACCOUNT_SETTINGS: '/account-settings', CHANGE_PROJECT: '/change-project', VERIFY_EMAIL: '/verify-email', VERIFY_EMAIL_RESEND: '/verify-email-resend', REPORTS: '/reports', + NOT_AUTHORISED: '/not-authorised', }; export const PASSWORD_RESET_TOKEN_PARAM = 'passwordResetToken'; -export const ADMIN_ONLY_ROUTES = [ROUTES.REPORTS]; +export const ADMIN_ONLY_ROUTES = [ + ROUTES.REPORTS, + ROUTES.SURVEY_RESUBMIT_SCREEN, + ROUTES.SURVEY_RESUBMIT_REVIEW, + ROUTES.SURVEY_RESUBMIT_SUCCESS, + ROUTES.SURVEY_RESUBMIT, +]; diff --git a/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx b/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx index a66abdb5e6..95e56e5733 100644 --- a/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/AutocompleteQuestion.tsx @@ -12,6 +12,7 @@ import { SurveyQuestionInputProps } from '../../types'; import { useAutocompleteOptions } from '../../api'; import { MOBILE_BREAKPOINT } from '../../constants'; import { Autocomplete as BaseAutocomplete, InputHelperText } from '../../components'; +import { useSurveyForm } from '../Survey'; const Autocomplete = styled(BaseAutocomplete)` width: calc(100% - 3.5rem); @@ -67,6 +68,7 @@ export const AutocompleteQuestion = ({ config = {}, controllerProps: { value: selectedValue = null, onChange, ref, invalid }, }: SurveyQuestionInputProps) => { + const { isResubmit } = useSurveyForm(); const [searchValue, setSearchValue] = useState(''); const { autocomplete = {} } = config!; const { attributes, createNew } = autocomplete; @@ -76,6 +78,9 @@ export const AutocompleteQuestion = ({ searchValue, ); + // TODO: renable this as part of RN-1274 + const canCreateNew = createNew && !isResubmit; + const getOptionSelected = (option: Option, selectedOption?: string | null) => { const value = typeof option === 'string' ? option : option?.value; return value === selectedOption; @@ -84,7 +89,7 @@ export const AutocompleteQuestion = ({ const getOptions = () => { const options = data || []; // If we can't create a new option, or there is no input value, or the input value is already in the options, or the value is already added, return the options as they are - if (!createNew || !searchValue || options.find(option => option.value === searchValue)) + if (!canCreateNew || !searchValue || options.find(option => option.value === searchValue)) return options; // if we have selected a newly created option, add it to the list of options if (selectedValue?.value === searchValue) diff --git a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx index 86fb30e7f9..eab5604cc5 100644 --- a/packages/datatrak-web/src/features/Questions/FileQuestion.tsx +++ b/packages/datatrak-web/src/features/Questions/FileQuestion.tsx @@ -59,11 +59,19 @@ export const FileQuestion = ({ const encodedFile = await createEncodedFile(file); // convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3 onChange({ - name: file?.name, + name: file.name, value: encodedFile, }); }; + const getInitialFiles = () => { + if (selectedFile?.value) { + return [new File([selectedFile.value as Blob], selectedFile.name)]; + } + return undefined; + }; + + const initialFiles = getInitialFiles(); return ( ); diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx index 53ed5859b1..2c7ab347e7 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyPaginator.tsx @@ -11,9 +11,11 @@ import { useSurveyForm } from '../SurveyContext'; import { Button } from '../../../components'; import { useIsMobile } from '../../../utils'; -const FormActions = styled.div` +const FormActions = styled.div<{ + $hasBackButton: boolean; +}>` display: flex; - justify-content: space-between; + justify-content: ${({ $hasBackButton }) => ($hasBackButton ? 'space-between' : 'flex-end')}; align-items: center; padding: 1rem 0.5rem; border-top: 1px solid ${props => props.theme.palette.divider}; @@ -27,6 +29,7 @@ const FormActions = styled.div` const ButtonGroup = styled.div` display: flex; + float: right; button, a { &:last-child { @@ -48,15 +51,21 @@ const BackButton = styled(Button).attrs({ } `; -type SurveyLayoutContextT = { isLoading: boolean; onStepPrevious: () => void }; +type SurveyLayoutContextT = { + isLoading: boolean; + onStepPrevious: () => void; + hasBackButton: boolean; +}; export const SurveyPaginator = () => { - const { isLast, isReviewScreen, openCancelConfirmation } = useSurveyForm(); + const { isLast, isReviewScreen, openCancelConfirmation, isResubmitReviewScreen } = + useSurveyForm(); const isMobile = useIsMobile(); - const { isLoading, onStepPrevious } = useOutletContext(); + const { isLoading, onStepPrevious, hasBackButton } = useOutletContext(); const getNextButtonText = () => { if (isReviewScreen) return 'Submit'; + if (isResubmitReviewScreen) return 'Resubmit'; if (isLast) { return isMobile ? 'Review' : 'Review and submit'; } @@ -66,10 +75,12 @@ export const SurveyPaginator = () => { const nextButtonText = getNextButtonText(); return ( - - - Back - + + {hasBackButton && ( + + Back + + )} Cancel diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx index 6b239884c8..2ad7edefe0 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveyQuestion.tsx @@ -73,7 +73,7 @@ export const SurveyQuestion = ({ ...props }: SurveyQuestionFieldProps) => { const { control, errors } = useFormContext(); - const { updateFormData, formData } = useSurveyForm(); + const { updateFormData, formData, isResubmit } = useSurveyForm(); const FieldComponent = QUESTION_TYPES[type]; if (!FieldComponent) { @@ -90,7 +90,7 @@ export const SurveyQuestion = ({ const getDefaultValue = () => { if (formData[name] !== undefined) return formData[name]; // This is so that the default value gets carried through to the component, and dates that have a visible value of 'today' have that value recognised when validating - if (type?.includes('Date')) return new Date(); + if (type?.includes('Date')) return isResubmit ? null : new Date(); return undefined; }; diff --git a/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx b/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx index 75254ad3db..cb4b3a6081 100644 --- a/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx +++ b/packages/datatrak-web/src/features/Survey/Components/SurveySideMenu/SurveySideMenu.tsx @@ -7,9 +7,10 @@ import styled from 'styled-components'; import { To, Link as RouterLink } from 'react-router-dom'; import { useFormContext } from 'react-hook-form'; import { Drawer as BaseDrawer, ListItem, List, ButtonProps } from '@material-ui/core'; +import { useSurveyForm } from '../../SurveyContext'; import { useIsMobile } from '../../../../utils'; import { getSurveyScreenNumber } from '../../utils'; -import { useSurveyForm } from '../../SurveyContext'; +import { useSurveyRouting } from '../../useSurveyRouting'; import { SideMenuButton } from './SideMenuButton'; export const SIDE_MENU_WIDTH = '20rem'; @@ -104,6 +105,7 @@ export const SurveySideMenu = () => { isReviewScreen, isSuccessScreen, isResponseScreen, + numberOfScreens, } = useSurveyForm(); if (isReviewScreen || isSuccessScreen || isResponseScreen) return null; const onChangeScreen = () => { @@ -121,6 +123,8 @@ export const SurveySideMenu = () => { }; const screenMenuItems = getFormattedScreens(); + const { getScreenPath } = useSurveyRouting(numberOfScreens); + return ( <> @@ -138,7 +142,7 @@ export const SurveySideMenu = () => { return ( theme.breakpoints.up('md')} { + flex: 1; + } +`; + +const StyledImg = styled.img` + aspect-ratio: 1; + width: 23rem; + max-width: 80%; + max-height: 50%; + margin-block-end: 2.75rem; +`; + +const Title = styled(Typography).attrs({ + variant: 'h2', +})` + font-size: 1.375rem; + font-weight: 600; + text-align: center; + margin-block-end: 1rem; + ${({ theme }) => theme.breakpoints.up('md')} { + font-size: 1.9rem; + margin-block-end: 1.19rem; + } +`; + +const Text = styled(Typography)` + max-width: 34.6rem; + width: 100%; + text-align: center; + margin-block-end: 1.875rem; +`; + +interface SurveySuccessProps { + text: string; + title: string; + showQrCode?: boolean; + children: React.ReactNode; +} + +export const SurveySuccess = ({ text, title, showQrCode, children }: SurveySuccessProps) => { + const { isLoggedIn } = useCurrentUserContext(); + + return ( + + + + {title} + {isLoggedIn && ( + <> + {text} + {children} + > + )} + + {showQrCode && } + + ); +}; diff --git a/packages/datatrak-web/src/features/Survey/Components/index.ts b/packages/datatrak-web/src/features/Survey/Components/index.ts index 97dc524bd8..b4b17c01ef 100644 --- a/packages/datatrak-web/src/features/Survey/Components/index.ts +++ b/packages/datatrak-web/src/features/Survey/Components/index.ts @@ -9,3 +9,4 @@ export { SurveyToolbar } from './SurveyToolbar'; export * from './SurveySideMenu'; export { SurveyReviewSection } from './SurveyReviewSection'; export { SurveyPaginator } from './SurveyPaginator'; +export { SurveySuccess } from './SurveySuccess'; diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx new file mode 100644 index 0000000000..c4a50e498b --- /dev/null +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveyResubmitSuccessScreen.tsx @@ -0,0 +1,38 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import styled from 'styled-components'; +import { Button } from '../../../components'; +import { SurveySuccess } from '../Components'; + +const ButtonGroup = styled.div` + max-width: 28rem; + width: 100%; +`; + +const DEV_ADMIN_PANEL_URL = 'https://dev-admin.tupaia.org'; + +export const SurveyResubmitSuccessScreen = () => { + const getBaseAdminPanelUrl = () => { + const { origin } = window.location; + if (origin.includes('localhost')) return DEV_ADMIN_PANEL_URL; + return origin.replace('datatrak', 'admin'); + }; + + const adminPanelUrl = `${getBaseAdminPanelUrl()}/surveys/survey-responses`; + return ( + + + + Return to admin panel + + + + ); +}; diff --git a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx index ef5cad41b7..c468bd01a2 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx +++ b/packages/datatrak-web/src/features/Survey/Screens/SurveySuccessScreen.tsx @@ -6,57 +6,11 @@ import React from 'react'; import { generatePath, useNavigate, useParams } from 'react-router-dom'; import styled from 'styled-components'; -import { Typography } from '@material-ui/core'; import { Button as BaseButton } from '../../../components'; import { useSurveyForm } from '../SurveyContext'; import { ROUTES } from '../../../constants'; import { useSurvey } from '../../../api/queries'; -import { SurveyQRCode } from '../SurveyQRCode'; -import { useCurrentUserContext } from '../../../api'; - -const Wrapper = styled.div` - display: flex; - flex: 1; - height: 100%; -`; -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - ${({ theme }) => theme.breakpoints.up('md')} { - flex: 1; - } -`; - -const StyledImg = styled.img` - aspect-ratio: 1; - width: 23rem; - max-width: 80%; - max-height: 50%; - margin-block-end: 2.75rem; -`; - -const Title = styled(Typography).attrs({ - variant: 'h2', -})` - font-size: 1.375rem; - font-weight: 600; - text-align: center; - margin-block-end: 1rem; - ${({ theme }) => theme.breakpoints.up('md')} { - font-size: 1.9rem; - margin-block-end: 1.19rem; - } -`; - -const Text = styled(Typography)` - max-width: 34.6rem; - width: 100%; - text-align: center; - margin-block-end: 1.875rem; -`; +import { SurveySuccess } from '../Components'; const ButtonGroup = styled.div` max-width: 28rem; @@ -70,7 +24,6 @@ const Button = styled(BaseButton)` `; export const SurveySuccessScreen = () => { - const { isLoggedIn } = useCurrentUserContext(); const params = useParams(); const navigate = useNavigate(); const { resetForm } = useSurveyForm(); @@ -96,27 +49,17 @@ export const SurveySuccessScreen = () => { const text = getText(); return ( - - - - Survey submitted! - {isLoggedIn && ( - <> - {text} - - {survey?.canRepeat && ( - - Repeat Survey - - )} - - Return to dashboard - - - > + + + {survey?.canRepeat && ( + + Repeat Survey + )} - - - + + Return to dashboard + + + ); }; diff --git a/packages/datatrak-web/src/features/Survey/Screens/index.ts b/packages/datatrak-web/src/features/Survey/Screens/index.ts index f9b6e909e9..c3bc40c90c 100644 --- a/packages/datatrak-web/src/features/Survey/Screens/index.ts +++ b/packages/datatrak-web/src/features/Survey/Screens/index.ts @@ -5,3 +5,4 @@ export { SurveyReviewScreen } from './SurveyReviewScreen'; export { SurveySuccessScreen } from './SurveySuccessScreen'; export { SurveyScreen } from './SurveyScreen'; +export { SurveyResubmitSuccessScreen } from './SurveyResubmitSuccessScreen'; diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx index 998ac02a57..b864cc5922 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/SurveyContext.tsx @@ -119,7 +119,12 @@ export const useSurveyForm = () => { const isLast = screenNumber === numberOfScreens; const isSuccessScreen = !!useMatch(ROUTES.SURVEY_SUCCESS); const isReviewScreen = !!useMatch(ROUTES.SURVEY_REVIEW); + const isResponseScreen = !!useMatch(ROUTES.SURVEY_RESPONSE); + const isResubmitScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN); + const isResubmitReviewScreen = !!useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); + const isResubmit = + !!useMatch(ROUTES.SURVEY_RESUBMIT) || isResubmitScreen || isResubmitReviewScreen; const toggleSideMenu = () => { dispatch({ type: ACTION_TYPES.TOGGLE_SIDE_MENU }); @@ -165,5 +170,8 @@ export const useSurveyForm = () => { getAnswerByQuestionId, openCancelConfirmation, closeCancelConfirmation, + isResubmitScreen, + isResubmitReviewScreen, + isResubmit, }; }; diff --git a/packages/datatrak-web/src/features/Survey/SurveyContext/utils.ts b/packages/datatrak-web/src/features/Survey/SurveyContext/utils.ts index 14f72cadcd..c23c7206d4 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyContext/utils.ts +++ b/packages/datatrak-web/src/features/Survey/SurveyContext/utils.ts @@ -12,6 +12,7 @@ import { } from '@tupaia/types'; import { SurveyScreenComponent } from '../../../types'; import { generateMongoId, generateShortId } from './generateId'; +import { stripTimezoneFromDate } from '@tupaia/utils'; export const getIsQuestionVisible = ( question: SurveyScreenComponent, @@ -146,14 +147,14 @@ const getArithmeticResult = ( return result; }; -const resetInvisibleQuestions = ( +const resetInvisibleAndFilteredQuestions = ( oldFormData: Record, updates: Record, screenComponents: SurveyScreenComponent[], ) => { const updatedFormData = { ...oldFormData, ...updates }; screenComponents?.forEach(component => { - const { questionId, visibilityCriteria } = component; + const { questionId, visibilityCriteria, type } = component; // if the question is not visible and is not set to always be hidden and has a value, reset the value if ( @@ -164,6 +165,34 @@ const resetInvisibleQuestions = ( ) { updatedFormData[questionId] = undefined; } + if ( + // if the question is an entity question and the value has changed, reset the value of all entity questions that depend on this question + (type === QuestionType.Entity || type === QuestionType.PrimaryEntity) && + updatedFormData.hasOwnProperty(questionId) && + updatedFormData[questionId] !== oldFormData[questionId] + ) { + // get all entity questions that depend on this question + const entityQuestionsFilteredByValue = screenComponents.filter(question => { + if (!question.config?.entity) return false; + const { entity } = question.config; + if (entity.filter) { + const { filter } = entity; + return Object.values(filter).some(filterValue => { + if (typeof filterValue === 'object' && 'questionId' in filterValue) { + return filterValue.questionId === questionId; + } + return false; + }); + } + return false; + }); + + // reset the value of all entity questions that depend on this question + entityQuestionsFilteredByValue.forEach(entityQuestion => { + const { questionId: entityQuestionId } = entityQuestion; + updatedFormData[entityQuestionId] = undefined; + }); + } }); return updatedFormData; @@ -239,14 +268,36 @@ export const generateCodeForCodeGeneratorQuestions = ( return formDataCopy; }; +/** + * @description Remove timezone from date answers so that the date is displayed correctly in the UI no matter the timezone. On submission these will be added back. + */ +const removeTimezoneFromDateAnswers = (updates, screenComponents: SurveyScreenComponent[]) => { + const updatedAnswers = { ...updates }; + screenComponents?.forEach(question => { + const { questionId, type } = question; + if (type.includes('Date')) { + if (updates[questionId]) { + updatedAnswers[questionId] = stripTimezoneFromDate(updates[questionId]); + } + } + }); + return updatedAnswers; +}; + export const getUpdatedFormData = ( updates: Record, formData: Record, screenComponents: SurveyScreenComponent[], ) => { - // reset the values of invisible questions first, in case the value of the invisible question is used in the formula of another question - const resetInvisibleQuestionData = resetInvisibleQuestions(formData, updates, screenComponents); - return updateDependentQuestions(resetInvisibleQuestionData, screenComponents); + const updatedValues = removeTimezoneFromDateAnswers(updates, screenComponents); + + // reset the values of invisible questions first, in case the value of the invisible question is used in the formula of another question. Also reset the value of filtered entity questions + const resetQuestionData = resetInvisibleAndFilteredQuestions( + formData, + updatedValues, + screenComponents, + ); + return updateDependentQuestions(resetQuestionData, screenComponents); }; export const getArithmeticDisplayAnswer = (config, answer, formData) => { diff --git a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx index e2dcb63419..d4af35cb97 100644 --- a/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx +++ b/packages/datatrak-web/src/features/Survey/SurveyLayout.tsx @@ -9,12 +9,13 @@ import { useFormContext } from 'react-hook-form'; import styled from 'styled-components'; import { Paper as MuiPaper } from '@material-ui/core'; import { SpinningLoader } from '@tupaia/ui-components'; +import { ROUTES } from '../../constants'; +import { useResubmitSurveyResponse, useSubmitSurveyResponse } from '../../api/mutations'; import { SurveyParams } from '../../types'; import { useSurveyForm } from './SurveyContext'; import { SIDE_MENU_WIDTH, SurveySideMenu } from './Components'; -import { ROUTES } from '../../constants'; -import { useSubmitSurveyResponse } from '../../api/mutations'; import { getErrorsByScreen } from './utils'; +import { useSurveyRouting } from './useSurveyRouting'; const ScrollableLayout = styled.div<{ $sideMenuClosed?: boolean; @@ -82,10 +83,14 @@ export const SurveyLayout = () => { isReviewScreen, isResponseScreen, visibleScreens, + isResubmitReviewScreen, } = useSurveyForm(); const { handleSubmit, getValues } = useFormContext(); const { mutate: submitSurveyResponse, isLoading: isSubmittingSurveyResponse } = useSubmitSurveyResponse(); + const { mutate: resubmitSurveyResponse, isLoading: isResubmittingSurveyResponse } = + useResubmitSurveyResponse(); + const { back, next } = useSurveyRouting(numberOfScreens); const handleStep = (path, data) => { updateFormData({ ...formData, ...data }); @@ -94,26 +99,11 @@ export const SurveyLayout = () => { const onStepPrevious = () => { const data = getValues(); - let path = ROUTES.SURVEY_SELECT; - const prevScreenNumber = isReviewScreen ? numberOfScreens : screenNumber! - 1; - if (prevScreenNumber) { - path = generatePath(ROUTES.SURVEY_SCREEN, { - ...params, - screenNumber: String(prevScreenNumber), - }); - } - - handleStep(path, data); + handleStep(back, data); }; const navigateNext = data => { - const path = isLast - ? generatePath(ROUTES.SURVEY_REVIEW, params) - : generatePath(ROUTES.SURVEY_SCREEN, { - ...params, - screenNumber: String(screenNumber! + 1), - }); - handleStep(path, data); + handleStep(next, data); }; const onError = errors => { @@ -143,20 +133,23 @@ export const SurveyLayout = () => { }; const onSubmit = data => { - if (isReviewScreen) return submitSurveyResponse(data); + const submitAction = isResubmitReviewScreen ? resubmitSurveyResponse : submitSurveyResponse; + if (isReviewScreen || isResubmitReviewScreen) return submitAction(data); return navigateNext(data); }; const handleClickSubmit = handleSubmit(onSubmit, onError); + const showLoader = isSubmittingSurveyResponse || isResubmittingSurveyResponse; + return ( <> - - {isSubmittingSurveyResponse && ( + + {showLoader && ( diff --git a/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts b/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts new file mode 100644 index 0000000000..31768cdc77 --- /dev/null +++ b/packages/datatrak-web/src/features/Survey/useSurveyRouting.ts @@ -0,0 +1,48 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { generatePath, useParams, useMatch } from 'react-router'; +import { ROUTES } from '../../constants'; + +export const useSurveyRouting = numberOfScreens => { + const isResubmitReview = useMatch(ROUTES.SURVEY_RESUBMIT_REVIEW); + const isResubmit = useMatch(ROUTES.SURVEY_RESUBMIT_SCREEN) || isResubmitReview; + const isReview = useMatch(ROUTES.SURVEY_REVIEW) || isResubmitReview; + const params = useParams(); + + const getScreenPath = (screenNumber: number) => { + if (isResubmit) { + return generatePath(ROUTES.SURVEY_RESUBMIT_SCREEN, { + ...params, + screenNumber: String(screenNumber), + }); + } + return generatePath(ROUTES.SURVEY_SCREEN, { + ...params, + screenNumber: String(screenNumber), + }); + }; + + const getNextPath = () => { + if (isReview) return null; + if (params.screenNumber && parseInt(params.screenNumber) === numberOfScreens) { + const REVIEW_PATH = isResubmit ? ROUTES.SURVEY_RESUBMIT_REVIEW : ROUTES.SURVEY_REVIEW; + return generatePath(REVIEW_PATH, params); + } + return getScreenPath(parseInt(params.screenNumber ?? '1') + 1); + }; + + const getPreviousPath = () => { + if (isReview) return getScreenPath(numberOfScreens); + if (!params.screenNumber || params.screenNumber === '1') + return isResubmit ? null : generatePath(ROUTES.SURVEY_SELECT); + return getScreenPath(parseInt(params.screenNumber) - 1); + }; + + return { + next: getNextPath(), + back: getPreviousPath(), + getScreenPath, + }; +}; diff --git a/packages/datatrak-web/src/features/index.ts b/packages/datatrak-web/src/features/index.ts index 22e48c0b2b..787d45e63f 100644 --- a/packages/datatrak-web/src/features/index.ts +++ b/packages/datatrak-web/src/features/index.ts @@ -15,6 +15,7 @@ export { getAllSurveyComponents, SurveySideMenu, useValidationResolver, + SurveyResubmitSuccessScreen, } from './Survey'; export { RequestProjectAccess } from './RequestProjectAccess'; export { MobileAppPrompt } from './MobileAppPrompt'; diff --git a/packages/datatrak-web/src/routes/Routes.tsx b/packages/datatrak-web/src/routes/Routes.tsx index 5b56266551..fc139c305a 100644 --- a/packages/datatrak-web/src/routes/Routes.tsx +++ b/packages/datatrak-web/src/routes/Routes.tsx @@ -19,6 +19,7 @@ import { ResetPasswordPage, AccountSettingsPage, ReportsPage, + NotAuthorisedPage, } from '../views'; import { useCurrentUserContext } from '../api'; import { ROUTES } from '../constants'; @@ -109,6 +110,7 @@ export const Routes = () => { } /> + } /> } /> diff --git a/packages/datatrak-web/src/routes/SurveyResponseRoute.tsx b/packages/datatrak-web/src/routes/SurveyResponseRoute.tsx new file mode 100644 index 0000000000..f41b17ea14 --- /dev/null +++ b/packages/datatrak-web/src/routes/SurveyResponseRoute.tsx @@ -0,0 +1,20 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { Outlet, useOutletContext, useParams } from 'react-router-dom'; +import { useSurveyResponse } from '../api'; + +export const SurveyResponseRoute = () => { + const { surveyResponseId } = useParams(); + + const outletContext = useOutletContext(); + + const { isLoading } = useSurveyResponse(surveyResponseId); + + // When the survey response is loading, don't render the children, in case there is a permissions error or the response is not found + if (isLoading) return null; + // Need to pass down the context to the other survey pages; necessary for layout and navigation + return ; +}; diff --git a/packages/datatrak-web/src/routes/SurveyRoutes.tsx b/packages/datatrak-web/src/routes/SurveyRoutes.tsx index 09f5aef228..a6b3a86041 100644 --- a/packages/datatrak-web/src/routes/SurveyRoutes.tsx +++ b/packages/datatrak-web/src/routes/SurveyRoutes.tsx @@ -13,24 +13,26 @@ import { SurveyReviewScreen, SurveyScreen, SurveySuccessScreen, + SurveyResubmitSuccessScreen, } from '../views'; import { SurveyLayout, useSurveyForm } from '../features'; import { useCurrentUserContext, useSurvey } from '../api'; +import { SurveyResponseRoute } from './SurveyResponseRoute'; // Redirect to the start of the survey if no screen number is provided -const SurveyStartRedirect = () => { +const SurveyStartRedirect = ({ baseRoute = ROUTES.SURVEY_SCREEN }) => { const params = useParams(); - const path = generatePath(ROUTES.SURVEY_SCREEN, { ...params, screenNumber: '1' }); + const path = generatePath(baseRoute, { ...params, screenNumber: '1' }); return ; }; // Redirect to the start of the survey if they try to access a screen that is not visible on the survey -const SurveyPageRedirect = ({ children }) => { +const SurveyPageRedirect = ({ children, baseRoute = ROUTES.SURVEY_SCREEN }) => { const { screenNumber } = useParams(); const { visibleScreens } = useSurveyForm(); if (visibleScreens && visibleScreens.length && visibleScreens.length < Number(screenNumber)) { - return ; + return ; } return children; }; @@ -46,7 +48,7 @@ const SurveyRoute = ({ children }) => { // Redirect to login page if the user is not logged in, otherwise show an error page if (isError) { return isLoggedIn ? ( - + ) : ( { return children; }; +const SurveyResubmitRedirect = () => { + const params = useParams(); + return ( + + ); +}; + export const SurveyRoutes = ( } /> } /> + } /> }> - } /> } /> } /> + }> + } /> + } /> + + + + } + /> + } /> + ); diff --git a/packages/datatrak-web/src/views/ErrorPage.tsx b/packages/datatrak-web/src/views/ErrorPage.tsx index d4b6937192..a450ed6a5e 100644 --- a/packages/datatrak-web/src/views/ErrorPage.tsx +++ b/packages/datatrak-web/src/views/ErrorPage.tsx @@ -7,13 +7,13 @@ import { DialogActions } from '@material-ui/core'; import { Button, ErrorDisplay } from '../components'; interface ErrorPageProps { - error?: Error; + errorMessage?: string; title?: string; } -export const ErrorPage = ({ error, title = '404: Page not found' }: ErrorPageProps) => { +export const ErrorPage = ({ errorMessage, title = '404: Page not found' }: ErrorPageProps) => { return ( - + Return to home diff --git a/packages/datatrak-web/src/views/NotAuthorisedPage.tsx b/packages/datatrak-web/src/views/NotAuthorisedPage.tsx new file mode 100644 index 0000000000..996b42296f --- /dev/null +++ b/packages/datatrak-web/src/views/NotAuthorisedPage.tsx @@ -0,0 +1,23 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { Navigate, useLocation } from 'react-router'; +import { ErrorPage } from './ErrorPage'; + +/** + * Wrapper Page for displaying an error message from location state + */ +export const NotAuthorisedPage = () => { + const location = useLocation(); + // Casting here because location.state can be anything + const { state } = location as { + state: { + errorMessage: string; + }; + }; + // If the user has been mistakenly redirected to this page, redirect them to the home page + if (!state?.errorMessage) return ; + return ; +}; diff --git a/packages/datatrak-web/src/views/SurveyPage.tsx b/packages/datatrak-web/src/views/SurveyPage.tsx index ec28f86a75..2c805c8c27 100644 --- a/packages/datatrak-web/src/views/SurveyPage.tsx +++ b/packages/datatrak-web/src/views/SurveyPage.tsx @@ -47,30 +47,17 @@ const SurveyScreenContainer = styled.div<{ `; const SurveyPageInner = () => { - const { screenNumber } = useParams(); - const { formData, isSuccessScreen, isResponseScreen, cancelModalOpen, closeCancelConfirmation } = - useSurveyForm(); + const { screenNumber, countryCode, surveyCode } = useParams(); + const { + formData, + isSuccessScreen, + isResponseScreen, + cancelModalOpen, + closeCancelConfirmation, + isResubmit, + } = useSurveyForm(); const resolver = useValidationResolver(); const formContext = useForm({ defaultValues: formData, reValidateMode: 'onSubmit', resolver }); - - return ( - - - - - {/* Use a key to render a different survey screen component for every screen number. This is so - that the screen can be easily initialised with the form data. See https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes */} - - - - - - ); -}; - -// The form provider has to be outside the outlet so that the form context is available to all. This is also so that the side menu can be outside of the 'SurveyLayout' page, because otherwise it rerenders on survey screen change, which makes it close and open again every time you change screen via the jump-to menu. The survey side menu needs to be inside the form provider so that it can access the form context to save form data -export const SurveyPage = () => { - const { countryCode, surveyCode } = useParams(); const { mutateAsync: editUser } = useEditUser(); const user = useCurrentUserContext(); const { data: survey } = useSurvey(surveyCode); @@ -78,7 +65,7 @@ export const SurveyPage = () => { // Update the user's preferred country if they start a survey in a different country useEffect(() => { - if (!surveyCountry?.code || !user.isLoggedIn) { + if (!surveyCountry?.code || !user.isLoggedIn || isResubmit) { return; } if (user.country?.code !== countryCode) { @@ -93,7 +80,7 @@ export const SurveyPage = () => { // Update the user's preferred project if they start a survey in a different project to the saved project useEffect(() => { - if (!survey?.projectId || !user.isLoggedIn) { + if (!survey?.projectId || !user.isLoggedIn || isResubmit) { return; } const { projectId } = survey; @@ -107,6 +94,23 @@ export const SurveyPage = () => { } }, [survey?.id]); + return ( + + + + + {/* Use a key to render a different survey screen component for every screen number. This is so + that the screen can be easily initialised with the form data. See https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes */} + + + + + + ); +}; + +// The form provider has to be outside the outlet so that the form context is available to all. This is also so that the side menu can be outside of the 'SurveyLayout' page, because otherwise it rerenders on survey screen change, which makes it close and open again every time you change screen via the jump-to menu. The survey side menu needs to be inside the form provider so that it can access the form context to save form data +export const SurveyPage = () => { return ( diff --git a/packages/datatrak-web/src/views/SurveyResponsePage.tsx b/packages/datatrak-web/src/views/SurveyResponsePage.tsx index 4eb98bbb78..a26b22f878 100644 --- a/packages/datatrak-web/src/views/SurveyResponsePage.tsx +++ b/packages/datatrak-web/src/views/SurveyResponsePage.tsx @@ -1,10 +1,9 @@ /* * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useEffect } from 'react'; -import { useFormContext } from 'react-hook-form'; +import React from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import { Typography } from '@material-ui/core'; @@ -13,7 +12,6 @@ import { useSurveyResponse } from '../api/queries'; import { SurveyReviewSection } from '../features/Survey/Components'; import { Button, SurveyTickIcon } from '../components'; import { displayDate } from '../utils'; -import { useSurveyForm } from '../features'; const Header = styled.div` display: flex; @@ -80,25 +78,9 @@ const getSubHeadingText = surveyResponse => { export const SurveyResponsePage = () => { const { surveyResponseId } = useParams(); - const { setFormData } = useSurveyForm(); - const formContext = useFormContext(); const { data: surveyResponse } = useSurveyResponse(surveyResponseId); - const answers = surveyResponse?.answers || {}; const subHeading = getSubHeadingText(surveyResponse); - useEffect(() => { - if (answers) { - // Format the answers to be compatible with the form, i.e. parse stringified objects - const formattedAnswers = Object.entries(answers).reduce((acc, [key, value]) => { - // If the value is a stringified object, parse it - const isStringifiedObject = typeof value === 'string' && value.startsWith('{'); - return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value }; - }, {}); - formContext.reset(formattedAnswers); - setFormData(formattedAnswers); - } - }, [JSON.stringify(answers)]); - return ( <> diff --git a/packages/datatrak-web/src/views/index.ts b/packages/datatrak-web/src/views/index.ts index 77ba3a98b9..976f1338ee 100644 --- a/packages/datatrak-web/src/views/index.ts +++ b/packages/datatrak-web/src/views/index.ts @@ -13,9 +13,15 @@ export { VerifyEmailPage } from './VerifyEmailPage'; export { ErrorPage } from './ErrorPage'; export { ProjectSelectPage } from './ProjectSelectPage'; export { SurveyResponsePage } from './SurveyResponsePage'; -export { SurveySuccessScreen, SurveyReviewScreen, SurveyScreen } from '../features'; +export { + SurveySuccessScreen, + SurveyReviewScreen, + SurveyScreen, + SurveyResubmitSuccessScreen, +} from '../features'; export { RequestProjectAccessPage } from './RequestProjectAccessPage'; export { ForgotPasswordPage } from './ForgotPasswordPage'; export { ResetPasswordPage } from './ResetPasswordPage'; export { AccountSettingsPage } from './AccountSettingsPage'; export { ReportsPage } from './ReportsPage'; +export { NotAuthorisedPage } from './NotAuthorisedPage'; diff --git a/packages/server-utils/src/s3/S3Client.ts b/packages/server-utils/src/s3/S3Client.ts index 550421dc88..c91f5b1cd3 100644 --- a/packages/server-utils/src/s3/S3Client.ts +++ b/packages/server-utils/src/s3/S3Client.ts @@ -101,7 +101,7 @@ export class S3Client { const buffer = Buffer.from(encodedImageString, 'base64'); // use the file type from the image if it's available, otherwise default to png - const fileType = + let fileType = base64EncodedImage.includes('data:image') && base64EncodedImage.includes(';base64') ? base64EncodedImage.substring('data:image/'.length, base64EncodedImage.indexOf(';base64')) : 'png'; @@ -109,6 +109,8 @@ export class S3Client { // If is not an image file type, e.g. a pdf, throw an error if (!imageTypes.includes(fileType)) throw new Error(`File type ${fileType} is not supported`); + if (fileType === 'jpeg') fileType = 'jpg'; + const fileExtension = fileType.replace('+xml', ''); const filePath = getS3ImageFilePath(); diff --git a/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts new file mode 100644 index 0000000000..fc92dbf293 --- /dev/null +++ b/packages/types/src/types/requests/datatrak-web-server/ResubmitSurveyResponseRequest.ts @@ -0,0 +1,16 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import { Entity } from '../../models'; +import { + ResBody as SubmitSurveyResponseRequestResBody, + ReqBody as SubmitSurveyResponseRequestBody, +} from './SubmitSurveyResponseRequest'; + +export type Params = { originalSurveyResponseId: string }; +export type ResBody = SubmitSurveyResponseRequestResBody; +export type ReqBody = SubmitSurveyResponseRequestBody & { + entityId?: Entity['id']; +}; +export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts index 90201a7e82..270fafe450 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SingleSurveyResponseRequest.ts @@ -10,12 +10,13 @@ export type Params = { id: string; }; -export interface ResBody extends KeysToCamelCase { +export interface ResBody extends KeysToCamelCase> { answers: Record; countryName: Country['name']; entityName: Entity['name']; surveyName: Survey['name']; surveyCode: Survey['code']; + dataTime: Date; } export type ReqBody = Record; export type ReqQuery = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts index 91e4a12af9..e2864ea7a8 100644 --- a/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/SubmitSurveyResponseRequest.ts @@ -22,6 +22,7 @@ interface SurveyResponse { answers: Answers; startTime: string; timezone: string; + dataTime?: string; } export type Params = Record; diff --git a/packages/types/src/types/requests/datatrak-web-server/index.ts b/packages/types/src/types/requests/datatrak-web-server/index.ts index 7fb2c6887e..fcec54e1bc 100644 --- a/packages/types/src/types/requests/datatrak-web-server/index.ts +++ b/packages/types/src/types/requests/datatrak-web-server/index.ts @@ -9,6 +9,7 @@ export * as DatatrakWebSurveysRequest from './SurveysRequest'; export * as DatatrakWebProjectsRequest from './ProjectsRequest'; export * as DatatrakWebSurveyRequest from './SurveyRequest'; export * as DatatrakWebSubmitSurveyResponseRequest from './SubmitSurveyResponseRequest'; +export * as DatatrakWebResubmitSurveyResponseRequest from './ResubmitSurveyResponseRequest'; export * as DatatrakWebSurveyResponsesRequest from './SurveyResponsesRequest'; export * as DatatrakWebRecentSurveysRequest from './RecentSurveysRequest'; export * as DatatrakWebSingleSurveyResponseRequest from './SingleSurveyResponseRequest'; diff --git a/packages/types/src/types/requests/index.ts b/packages/types/src/types/requests/index.ts index 8a82756dbc..d3010441b9 100644 --- a/packages/types/src/types/requests/index.ts +++ b/packages/types/src/types/requests/index.ts @@ -11,6 +11,7 @@ export { DatatrakWebUserRequest, DatatrakWebSurveysRequest, DatatrakWebSubmitSurveyResponseRequest, + DatatrakWebResubmitSurveyResponseRequest, DatatrakWebSurveyRequest, DatatrakWebSurveyResponsesRequest, DatatrakWebRecentSurveysRequest, diff --git a/packages/ui-components/src/components/Inputs/FileUploadField.tsx b/packages/ui-components/src/components/Inputs/FileUploadField.tsx index a1cf6d9d99..56d7ac2884 100644 --- a/packages/ui-components/src/components/Inputs/FileUploadField.tsx +++ b/packages/ui-components/src/components/Inputs/FileUploadField.tsx @@ -154,6 +154,7 @@ export interface FileUploadFieldProps { * has a file selected. */ disabled?: boolean; + initialFiles?: File[]; } export const FileUploadField = ({ @@ -169,6 +170,7 @@ export const FileUploadField = ({ tooltip, disabled = false, fileName, + initialFiles = [], }: FileUploadFieldProps) => { if (disabled) return ( @@ -191,7 +193,7 @@ export const FileUploadField = ({ * * This array uses set semantics. See {@link onDropAccepted}. */ - const [files, setFiles] = useState([]); + const [files, setFiles] = useState(initialFiles); const hasFileSelected = files.length > 0; /** @@ -220,6 +222,13 @@ export const FileUploadField = ({ /** Propagates file selection changes to parent */ useEffect(() => { + // if the files are the same as the initial files, don't call onChange + if ( + initialFiles.length && + files.length && + files.every((file, i) => initialFiles?.[i]?.name === file.name) + ) + return; onChange(files); }, [files]); diff --git a/packages/ui-components/src/components/Inputs/TextField.tsx b/packages/ui-components/src/components/Inputs/TextField.tsx index 71785455f8..153213a16a 100644 --- a/packages/ui-components/src/components/Inputs/TextField.tsx +++ b/packages/ui-components/src/components/Inputs/TextField.tsx @@ -22,7 +22,6 @@ const StyledTextField = styled(MuiTextField)` .MuiInputBase-input { color: ${props => props.theme.palette.text.primary}; font-weight: 400; - line-height: 1.2rem; border-radius: 3px; } diff --git a/packages/utils/package.json b/packages/utils/package.json index d2560e7bc4..c0c9047208 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,6 +28,8 @@ "bson-objectid": "^1.2.2", "case": "^1.5.5", "countrynames": "^0.1.1", + "date-fns": "^2.29.2", + "date-fns-tz": "^2.0.1", "fast-memoize": "^2.5.2", "jsonwebtoken": "^8.5.1", "lodash.get": "^4.4.2", diff --git a/packages/utils/src/__tests__/timezone.test.js b/packages/utils/src/__tests__/timezone.test.js new file mode 100644 index 0000000000..e6dc9528d2 --- /dev/null +++ b/packages/utils/src/__tests__/timezone.test.js @@ -0,0 +1,23 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { getOffsetForTimezone } from '../timezone'; + +describe('getOffsetForTimezone', () => { + it('Should return the correct offset for the timezone', async () => { + expect(getOffsetForTimezone('Pacific/Auckland')).toBe('+12:00'); + expect(getOffsetForTimezone('Pacific/Chatham')).toBe('+12:45'); + expect(getOffsetForTimezone('Pacific/Fiji')).toBe('+12:00'); + expect(getOffsetForTimezone('Asia/Kolkata')).toBe('+05:30'); + expect(getOffsetForTimezone('Asia/Kathmandu')).toBe('+05:45'); + expect(getOffsetForTimezone('Asia/Tehran')).toBe('+03:30'); + expect(getOffsetForTimezone('Europe/London')).toBe('+01:00'); + expect(getOffsetForTimezone('America/New_York')).toBe('-04:00'); + expect(getOffsetForTimezone('America/Los_Angeles')).toBe('-07:00'); + expect(getOffsetForTimezone('Pacific/Honolulu')).toBe('-10:00'); + expect(getOffsetForTimezone('Australia/Lord_Howe', new Date('2021-03-08'))).toBe('+11:00'); + expect(getOffsetForTimezone('Australia/Lord_Howe', new Date('2021-07-08'))).toBe('+10:30'); + }); +}); diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 3d75b896df..6f041c377a 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -42,3 +42,4 @@ export { createClassExtendingProxy } from './proxy'; export { fetchPatiently } from './fetchPatiently'; export { oneSecondSleep, sleep } from './sleep'; export { getUniqueSurveyQuestionFileName } from './getUniqueSurveyQuestionFileName'; +export { formatDateInTimezone, getOffsetForTimezone } from './timezone'; diff --git a/packages/utils/src/timezone.js b/packages/utils/src/timezone.js new file mode 100644 index 0000000000..79cc842ff0 --- /dev/null +++ b/packages/utils/src/timezone.js @@ -0,0 +1,31 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { formatInTimeZone, getTimezoneOffset } from 'date-fns-tz'; + +export const formatDateInTimezone = (date, timezone, format = "yyyy-MM-dd'T'HH:mm:ssXXX") => { + return formatInTimeZone(date, timezone, format); +}; + +export const getOffsetForTimezone = (timezone, date) => { + // the offset is given in ms, so we need to convert it to hours + const offset = getTimezoneOffset(timezone, date) / 60 / 60 / 1000; + + // round to 2 decimal places + const offsetDec = Math.round(offset * 100) / 100; + + // split the offset into hours and minutes + const hours = Math.abs(Math.floor(offsetDec)); + const mins = (offsetDec % 1) * 60 || '00'; + // add the correct prefix + const prefix = offset > 0 ? '+' : '-'; + // add leading zero to hours if needed + const leadingZero = Math.abs(hours) < 10 ? '0' : ''; + + // create the offset string + const offsetStr = `${prefix}${leadingZero}${hours}:${mins}`; + + return offsetStr; +}; diff --git a/yarn.lock b/yarn.lock index 33fc6643ef..fadef962f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12041,6 +12041,8 @@ __metadata: "@types/lodash.keyby": ^4.6.0 "@types/lodash.sortby": ^4.6.0 camelcase-keys: ^6.2.2 + date-fns: ^2.29.2 + date-fns-tz: ^2.0.1 express: ^4.19.2 lodash.groupby: ^4.6.0 lodash.keyby: ^4.6.0 @@ -12731,6 +12733,8 @@ __metadata: bson-objectid: ^1.2.2 case: ^1.5.5 countrynames: ^0.1.1 + date-fns: ^2.29.2 + date-fns-tz: ^2.0.1 fast-memoize: ^2.5.2 jsonwebtoken: ^8.5.1 lodash.get: ^4.4.2 @@ -20182,6 +20186,15 @@ __metadata: languageName: node linkType: hard +"date-fns-tz@npm:^2.0.1": + version: 2.0.1 + resolution: "date-fns-tz@npm:2.0.1" + peerDependencies: + date-fns: 2.x + checksum: 2d2bb3d67f1ac876a7bd3bbe027aba61347d21c74f20a3f014c53d6057a7a1653ed3021c2d9271dc24411ab38f82763fec362c4f24e944465172cd80ee8c4b5f + languageName: node + linkType: hard + "date-fns@npm:^1.27.2, date-fns@npm:^1.3.0": version: 1.30.1 resolution: "date-fns@npm:1.30.1"