Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datatrakWeb): RN-1243: resubmit surveys via Datatrak Web #5638

Merged
merged 34 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fda8b92
WIP
alexd-bes May 13, 2024
6a8058f
Rearrange routes
alexd-bes May 13, 2024
b63f3d3
Ability to resubmit
alexd-bes May 14, 2024
af7fcf0
Ability to upload images in resubmission
alexd-bes May 14, 2024
20e4d54
Success screen
alexd-bes May 14, 2024
5ca9f76
Loading state
alexd-bes May 14, 2024
4f137cc
Disable create new autocomplete abilities in resubmit (temp)
alexd-bes May 14, 2024
b16feb0
tidy ups
alexd-bes May 14, 2024
dc24892
Remove unused import
alexd-bes May 14, 2024
a1484f7
Fix imports
alexd-bes May 14, 2024
2fc2a80
Update SurveyResponsePage.tsx
alexd-bes May 14, 2024
ecd9695
Fix tests
alexd-bes May 14, 2024
79532d8
Update packages/datatrak-web-server/src/routes/SingleSurveyResponseRo…
alexd-bes May 15, 2024
99c064c
feat(adminPanel): RN-1243: Resubmit survey response modal should link…
alexd-bes May 15, 2024
12d308d
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes May 22, 2024
732f1f5
Build fixes
alexd-bes May 22, 2024
1ec1193
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes May 23, 2024
74bd8e8
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes May 23, 2024
455eb49
Merge pull request #5718 from beyondessential/dev
avaek Jun 10, 2024
73e1de7
Apply primary entity answer to resubmit
alexd-bes Jun 10, 2024
6089c63
Fix breaking data_time questions
alexd-bes Jun 10, 2024
a092239
Fix build
alexd-bes Jun 10, 2024
a826d93
Fix survey responses with file uploads
alexd-bes Jun 10, 2024
8d8b169
Display file name for saved file questions and fix remove file value
alexd-bes Jun 10, 2024
a2c5cef
Use dataTime for date questions
alexd-bes Jun 11, 2024
a44fb2c
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes Jun 12, 2024
4efb651
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes Jun 19, 2024
c99d11f
fix permissions
alexd-bes Jun 20, 2024
4384ab4
Merge pull request #5754 from beyondessential/dev
avaek Jul 2, 2024
aa3ab0c
Send timezone through with resubmission
alexd-bes Jul 4, 2024
1366e62
Open up permissions
alexd-bes Jul 8, 2024
8e451a3
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes Jul 9, 2024
79212bf
Merge branch 'dev' into rn-1243-resubmit-surveys
alexd-bes Jul 23, 2024
a193aec
feat(dataTrak): RN-1274: Keep 'outdated' historical survey responses …
rohan-bes Aug 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
* Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd
*/

import { QuestionType } from '@tupaia/types';
import { findQuestionsInSurvey } from '../../../dataAccessors';
import { S3, S3Client } from '@tupaia/server-utils';
import { UploadError } from '@tupaia/utils';

export const handleSurveyResponse = async (models, updatedFields, recordType, surveyResponse) => {
const surveyResponseUpdateFields = { ...updatedFields };
Expand Down Expand Up @@ -32,13 +35,26 @@ export const handleAnswers = async (models, updatedFields, surveyResponse) => {

await Promise.all(
questionCodes.map(async questionCode => {
const answer = updatedAnswers[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 the answer is a photo and the answer is updated and the value is a base64 encoded image, upload the image to S3 and update the answer to be the url
const validFileIdRegex = RegExp('^[a-f\\d]{24}$');
if (type === QuestionType.Photo && answer && !validFileIdRegex.test(answer)) {
try {
const base64 = updatedAnswers[questionCode];
const s3Client = new S3Client(new S3());
updatedAnswers[questionCode] = await s3Client.uploadImage(base64);
} catch (error) {
throw new UploadError(error);
}
}

if (!existingAnswer) {
if (isAnswerDeletion) {
return;
Expand Down
5 changes: 5 additions & 0 deletions packages/datatrak-web-server/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/
export const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,19 +28,46 @@ const DEFAULT_FIELDS = [
'id',
'survey.name',
'survey.code',
'user_id',
'country.code',
];

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) => {
const isBESAdmin = accessPolicy.allowsSome(undefined, BES_ADMIN_PERMISSION_GROUP);
const hasAdminPanelAccess = accessPolicy.allows(countryCode, TUPAIA_ADMIN_PANEL_PERMISSION_GROUP);
if (!isBESAdmin && !hasAdminPanelAccess) {
throw new PermissionsError('You do not have access to view this survey response');
}
};

export class SingleSurveyResponseRoute extends Route<SingleSurveyResponseRequest> {
public async buildResponse() {
const { ctx, params, query } = this.req;
const { ctx, params, query, models, accessPolicy } = this.req;
alexd-bes marked this conversation as resolved.
Show resolved Hide resolved
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, ...response } = surveyResponse;

if (userId !== id) {
assertCanViewSurveyResponse(accessPolicy, countryCode);
}

const answerList = await ctx.services.central.fetchResources('answers', {
filter: { survey_response_id: surveyResponse.id },
columns: ANSWER_COLUMNS,
Expand All @@ -51,6 +81,6 @@ export class SingleSurveyResponseRoute extends Route<SingleSurveyResponseRequest
);

// Don't return the answers in camel case because the keys are question IDs which we want in lowercase
return camelcaseKeys({ ...surveyResponse, answers });
return camelcaseKeys({ ...response, answers });
}
}
3 changes: 1 addition & 2 deletions packages/datatrak-web-server/src/routes/UserRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,8 +15,6 @@ export type UserRequest = Request<
DatatrakWebUserRequest.ReqQuery
>;

const TUPAIA_ADMIN_PANEL_PERMISSION_GROUP = 'Tupaia Admin Panel';

export class UserRoute extends Route<UserRequest> {
public async buildResponse() {
const { ctx, session, accessPolicy } = this.req;
Expand Down
7 changes: 6 additions & 1 deletion packages/datatrak-web/src/api/CurrentUserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ export const CurrentUserContextProvider = ({ children }: { children: React.React
}

if (currentUserQuery.isError) {
return <ErrorDisplay title="Error loading user" error={currentUserQuery.error as Error} />;
return (
<ErrorDisplay
title="Error loading user"
errorMessage={(currentUserQuery.error as Error)?.message}
/>
);
}

const data = currentUserQuery.data;
Expand Down
1 change: 1 addition & 0 deletions packages/datatrak-web/src/api/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export { useRequestDeleteAccount } from './useRequestDeleteAccount';
export { useOneTimeLogin } from './useOneTimeLogin';
export * from './useExportSurveyResponses';
export { useTupaiaRedirect } from './useTupaiaRedirect';
export { useResubmitSurveyResponse } from './useResubmitSurveyResponse';
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Tupaia
* Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd
*/

import { useMutation } from 'react-query';
import { generatePath, useNavigate, useParams } from 'react-router';
import { QuestionType } from '@tupaia/types';
import { getUniqueSurveyQuestionFileName } from '@tupaia/utils';
import { post } from '../api';
import { getAllSurveyComponents, useSurveyForm } from '../../features';
import { SurveyScreenComponent } from '../../types';
import { ROUTES } from '../../constants';
import { AnswersT, isFileUploadAnswer } from './useSubmitSurvey';

const processAnswers = (
answers: AnswersT,
questionsById: Record<string, SurveyScreenComponent>,
) => {
const files: File[] = [];
const formattedAnswers = Object.entries(answers).reduce((acc, [questionId, answer]) => {
const { code, type } = questionsById[questionId];
if (!code) return acc;

if (type === QuestionType.File && isFileUploadAnswer(answer) && answer.value instanceof File) {
// Create a new file with a unique name, and add it to the files array, so we can add to the FormData, as this is what the central server expects
const uniqueFileName = getUniqueSurveyQuestionFileName(answer.name);
files.push(
new File([answer.value as Blob], uniqueFileName, {
type: answer.value.type,
}),
);
return {
...acc,
[code]: uniqueFileName,
};
}
return {
...acc,
[code]: answer,
};
}, {});

return {
answers: formattedAnswers,
files,
};
};

export const useResubmitSurveyResponse = () => {
const navigate = useNavigate();
const params = useParams();
const { surveyResponseId } = params;
const { surveyScreens, resetForm } = useSurveyForm();
const allScreenComponents = getAllSurveyComponents(surveyScreens);
const questionsById = allScreenComponents.reduce((acc, component) => {
return {
...acc,
[component.questionId]: component,
};
}, {});
return useMutation<any, Error, AnswersT, unknown>(
async (surveyAnswers: AnswersT) => {
if (!surveyAnswers) {
return;
}
const { answers, files } = processAnswers(surveyAnswers, questionsById);

const formData = new FormData();
formData.append('payload', JSON.stringify({ answers }));
files.forEach(file => {
formData.append(file.name, file);
});

return post(`surveyResponse/${surveyResponseId}/resubmit`, {
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
},
{
onSuccess: () => {
resetForm();
navigate(generatePath(ROUTES.SURVEY_RESUBMIT_SUCCESS, params));
},
},
);
};
52 changes: 50 additions & 2 deletions packages/datatrak-web/src/api/mutations/useSubmitSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@
import { useMutation, useQueryClient } from 'react-query';
import { generatePath, useNavigate, useParams } from 'react-router';
import { getBrowserTimeZone } from '@tupaia/utils';
import { QuestionType } from '@tupaia/types';
import { Coconut } from '../../components';
import { post, useCurrentUserContext, useEntityByCode } from '../../api';
import { ROUTES } from '../../constants';
import { getAllSurveyComponents, useSurveyForm } from '../../features';
import { useSurvey } from '../queries';
import { gaEvent, successToast } from '../../utils';

type Answer = string | number | boolean | null | undefined;
type Base64 = string | null | ArrayBuffer;

type FileAnswerT = {
name: string;
value?: Base64 | File;
};

type Answer = string | number | boolean | null | undefined | FileAnswerT;

export type AnswersT = Record<string, Answer>;

Expand All @@ -35,6 +43,45 @@ export const useSurveyResponseData = () => {
};
};

export const isFileUploadAnswer = (answer: Answer): answer is FileAnswerT => {
if (!answer || typeof answer !== 'object') return false;
return 'value' in answer;
};

const createEncodedFile = (fileObject?: File): Promise<Base64> => {
if (!fileObject) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
const reader = new FileReader();

reader.onload = () => {
resolve(reader.result);
};

reader.onerror = reject;

reader.readAsDataURL(fileObject);
});
};

const processAnswers = async (answers: AnswersT, questionsById) => {
const formattedAnswers = { ...answers };
for (const [questionId, answer] of Object.entries(answers)) {
const question = questionsById[questionId];
if (!question) continue;
if (question.type === QuestionType.File && isFileUploadAnswer(answer)) {
// convert to an object with an encoded file so that it can be handled in the backend and uploaded to s3
const encodedFile = await createEncodedFile(answer.value as File);
formattedAnswers[questionId] = {
name: answer.name,
value: encodedFile,
};
}
}
return formattedAnswers;
};

export const useSubmitSurvey = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
Expand All @@ -50,9 +97,10 @@ export const useSubmitSurvey = () => {
if (!answers) {
return;
}
const formattedAnswers = await processAnswers(answers, surveyResponseData.questions);

return post('submitSurvey', {
data: { ...surveyResponseData, answers },
data: { ...surveyResponseData, answers: formattedAnswers },
});
},
{
Expand Down
30 changes: 28 additions & 2 deletions packages/datatrak-web/src/api/queries/useSurveyResponse.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
/*
* 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 { useNavigate } from 'react-router';
import { DatatrakWebSingleSurveyResponseRequest } from '@tupaia/types';
import { get } from '../api';
import { ROUTES } from '../../constants';
import { errorToast } from '../../utils';
import { useSurveyForm } from '../../features';

export const useSurveyResponse = (surveyResponseId?: string) => {
const { setFormData } = useSurveyForm();
const navigate = useNavigate();

return useQuery(
['surveyResponse', surveyResponseId],
(): Promise<DatatrakWebSingleSurveyResponseRequest.ResBody> =>
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 => {
// 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('{');
return { ...acc, [key]: isStringifiedObject ? JSON.parse(value) : value };
}, {});
setFormData(formattedAnswers);
},
},
);
};
6 changes: 3 additions & 3 deletions packages/datatrak-web/src/components/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ const Container = styled(Paper).attrs({
`;

export const ErrorDisplay = ({
error,
errorMessage,
children,
title,
}: {
error?: Error | null;
errorMessage?: string | null;
children?: ReactNode;
title;
}) => {
return (
<Wrapper>
<Container>
<Typography variant="h1">{title}</Typography>
{error && <Typography variant="body1">{error.message}</Typography>}
{errorMessage && <Typography variant="body1">{errorMessage}</Typography>}
{children}
</Container>
</Wrapper>
Expand Down
Loading