From d3cace3f24277c4bddf9ede906fe1f48f87b4b32 Mon Sep 17 00:00:00 2001 From: Justyn Oh Date: Mon, 18 Mar 2024 23:30:25 +0800 Subject: [PATCH] feat(mrf): backend validation for field locking --- .../public-form/PublicFormContext.tsx | 6 + .../public-form/PublicFormProvider.tsx | 12 +- .../features/public-form/PublicFormService.ts | 4 +- .../FormFields/FormFieldsContainer.tsx | 8 +- .../src/features/public-form/mutations.ts | 5 +- .../public-form/utils/createSubmission.ts | 51 ++- .../public-form/utils/decryptSubmission.ts | 2 + shared/utils/response-v3.ts | 106 ++++++ .../multirespondent-submission.controller.ts | 11 +- .../multirespondent-submission.middleware.ts | 338 ++++++++++++++---- .../multirespondent-submission.service.ts | 44 ++- .../multirespondent-submission.types.ts | 1 + src/app/utils/logic-adaptor.ts | 88 ++++- 13 files changed, 591 insertions(+), 85 deletions(-) create mode 100644 shared/utils/response-v3.ts diff --git a/frontend/src/features/public-form/PublicFormContext.tsx b/frontend/src/features/public-form/PublicFormContext.tsx index a6ee8cbcb6..d17a84bf4f 100644 --- a/frontend/src/features/public-form/PublicFormContext.tsx +++ b/frontend/src/features/public-form/PublicFormContext.tsx @@ -11,6 +11,8 @@ import { UseQueryResult } from 'react-query' import { MultirespondentSubmissionDto } from '~shared/types' import { PublicFormViewDto } from '~shared/types/form' +import { decryptSubmission } from './utils/decryptSubmission' + export type SubmissionData = { /** Submission id */ id: string | undefined @@ -65,6 +67,10 @@ export interface PublicFormContextProps setNumVisibleFields?: Dispatch> encryptedPreviousSubmission?: MultirespondentSubmissionDto + previousSubmission?: ReturnType + setPreviousSubmission: ( + previousSubmission: ReturnType, + ) => void } export const PublicFormContext = createContext< diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index d485c7f26c..8d5df4d25e 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -61,6 +61,7 @@ import { } from '~features/verifiable-fields' import { FormNotFound } from './components/FormNotFound' +import { decryptSubmission } from './utils/decryptSubmission' import { usePublicAuthMutations, usePublicFormMutations } from './mutations' import { PublicFormContext, SubmissionData } from './PublicFormContext' import { useEncryptedSubmission, usePublicFormView } from './queries' @@ -148,6 +149,9 @@ export const PublicFormProvider = ({ /* enabled= */ !submissionData, ) + const [previousSubmission, setPreviousSubmission] = + useState>() + // Replace form fields, logic, and workflow with the previous version for MRF consistency. if (data && encryptedPreviousSubmission) { data.form.form_fields = encryptedPreviousSubmission.form_fields @@ -300,7 +304,11 @@ export const PublicFormProvider = ({ submitStorageModeFormFetchMutation, submitMultirespondentFormMutation, updateMultirespondentSubmissionMutation, - } = usePublicFormMutations(formId, previousSubmissionId) + } = usePublicFormMutations( + formId, + previousSubmissionId, + previousSubmission?.submissionSecretKey, + ) const { handleLogoutMutation } = usePublicAuthMutations(formId) @@ -694,6 +702,8 @@ export const PublicFormProvider = ({ isPreview: false, setNumVisibleFields, encryptedPreviousSubmission, + previousSubmission, + setPreviousSubmission, ...commonFormValues, ...data, ...rest, diff --git a/frontend/src/features/public-form/PublicFormService.ts b/frontend/src/features/public-form/PublicFormService.ts index 7128dd8b9b..defcebfc45 100644 --- a/frontend/src/features/public-form/PublicFormService.ts +++ b/frontend/src/features/public-form/PublicFormService.ts @@ -144,7 +144,7 @@ export type SubmitStorageFormWithVirusScanningArgs = export type SubmitMultirespondentFormWithVirusScanningArgs = SubmitEmailFormArgs & { - // publicKey: string + submissionSecretKey?: string fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[] } @@ -369,6 +369,7 @@ export const updateMultirespondentSubmission = async ({ captchaType = '', responseMetadata, fieldIdToQuarantineKeyMap, + submissionSecretKey, }: SubmitMultirespondentFormWithVirusScanningArgs & { submissionId?: string }) => { @@ -383,6 +384,7 @@ export const updateMultirespondentSubmission = async ({ formFields, formInputs: filteredInputs, responseMetadata, + submissionSecretKey, version: MULTIRESPONDENT_FORM_SUBMISSION_VERSION, }, fieldIdToQuarantineKeyMap, diff --git a/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx b/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx index e21fe1253b..9fc11148a7 100644 --- a/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx +++ b/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import { useSearchParams } from 'react-router-dom' import { Box } from '@chakra-ui/react' @@ -24,11 +24,10 @@ export const FormFieldsContainer = (): JSX.Element | null => { handleSubmitForm, submissionData, encryptedPreviousSubmission, + previousSubmission, + setPreviousSubmission, } = usePublicFormContext() - const [previousSubmission, setPreviousSubmission] = - useState>() - const { submissionPublicKey = null, workflowStep } = encryptedPreviousSubmission ?? {} const [searchParams] = useSearchParams() @@ -120,6 +119,7 @@ export const FormFieldsContainer = (): JSX.Element | null => { handleSubmitForm, submissionPublicKey, queryParams.key, + setPreviousSubmission, encryptedPreviousSubmission, ]) diff --git a/frontend/src/features/public-form/mutations.ts b/frontend/src/features/public-form/mutations.ts index 4a21068ca3..b659f9bda3 100644 --- a/frontend/src/features/public-form/mutations.ts +++ b/frontend/src/features/public-form/mutations.ts @@ -72,6 +72,7 @@ export const usePublicAuthMutations = (formId: string) => { export const usePublicFormMutations = ( formId: string, submissionId?: string, + submissionSecretKey?: string, ) => { const submitEmailModeFormMutation = useMutation( (args: Omit) => { @@ -176,7 +177,9 @@ export const usePublicFormMutations = ( ) const updateMultirespondentSubmissionMutation = - useSubmitStorageModeFormMutation(updateMultirespondentSubmission) + useSubmitStorageModeFormMutation((args) => + updateMultirespondentSubmission({ ...args, submissionSecretKey }), + ) return { submitEmailModeFormMutation, diff --git a/frontend/src/features/public-form/utils/createSubmission.ts b/frontend/src/features/public-form/utils/createSubmission.ts index 506d6309aa..53eac457d3 100644 --- a/frontend/src/features/public-form/utils/createSubmission.ts +++ b/frontend/src/features/public-form/utils/createSubmission.ts @@ -94,6 +94,7 @@ type CreateStorageSubmissionFormDataArgs = CreateEmailSubmissionFormDataArgs & { type CreateMultirespondentSubmissionFormDataArgs = CreateEmailSubmissionFormDataArgs & { + submissionSecretKey?: string version: number } @@ -268,14 +269,55 @@ const createResponsesV3 = ( case BasicField.Uen: case BasicField.Date: case BasicField.CountryRegion: - case BasicField.YesNo: + case BasicField.YesNo: { + const input = formInputs[ff._id] as FormFieldValue + if (!input) continue + returnedInputs[ff._id] = { + fieldType: ff.fieldType, + answer: input, + } as FieldResponseV3 + break + } case BasicField.Email: - case BasicField.Mobile: - case BasicField.Table: - case BasicField.Checkbox: + case BasicField.Mobile: { + const input = formInputs[ff._id] as FormFieldValue + if (!input || !input.value) continue + returnedInputs[ff._id] = { + fieldType: ff.fieldType, + answer: input, + } as FieldResponseV3 + break + } + case BasicField.Table: { + const input = formInputs[ff._id] as FormFieldValue + if (!input) continue + if (input.every((row) => Object.values(row).every((value) => !value))) { + continue + } + returnedInputs[ff._id] = { + fieldType: ff.fieldType, + answer: input, + } as FieldResponseV3 + break + } + case BasicField.Checkbox: { + const input = formInputs[ff._id] as FormFieldValue + if (!input) continue + if ((!input.value || input.value.length === 0) && !input.othersInput) { + continue + } + returnedInputs[ff._id] = { + fieldType: ff.fieldType, + answer: input, + } as FieldResponseV3 + break + } case BasicField.Children: { const input = formInputs[ff._id] as FormFieldValue if (!input) continue + if (input.child.every((child) => child.every((value) => !value))) { + continue + } returnedInputs[ff._id] = { fieldType: ff.fieldType, answer: input, @@ -305,6 +347,7 @@ const createResponsesV3 = ( case BasicField.Radio: { const input = formInputs[ff._id] as FormFieldValue if (!input) continue + if (!input.value && !input.othersInput) continue returnedInputs[ff._id] = { fieldType: ff.fieldType, answer: input.othersInput diff --git a/frontend/src/features/public-form/utils/decryptSubmission.ts b/frontend/src/features/public-form/utils/decryptSubmission.ts index 7e70f5c97a..113d413d7f 100644 --- a/frontend/src/features/public-form/utils/decryptSubmission.ts +++ b/frontend/src/features/public-form/utils/decryptSubmission.ts @@ -11,6 +11,7 @@ export const decryptSubmission = ({ }): | (Omit & { responses: FieldResponsesV3 + submissionSecretKey: string }) | undefined => { if (!submission) throw Error('Encrypted submission undefined') @@ -28,5 +29,6 @@ export const decryptSubmission = ({ return { ...rest, responses: decryptedContent.responses as FieldResponsesV3, + submissionSecretKey: secretKey, } } diff --git a/shared/utils/response-v3.ts b/shared/utils/response-v3.ts new file mode 100644 index 0000000000..a671624b15 --- /dev/null +++ b/shared/utils/response-v3.ts @@ -0,0 +1,106 @@ +import { BasicField, FieldResponseV3 } from '../types' + +const areArraysEqual = ( + array1: T[], + array2: T[], + eq: (value1: T, value2: T) => boolean, +): boolean => + array1.length === array2.length && + array1.every((value1, i) => eq(value1, array2[i])) + +export const areFieldResponseV3sEqual = ( + response1: FieldResponseV3, + response2: FieldResponseV3, +): boolean => { + if (response1.fieldType !== response2.fieldType) return false + + switch (response1.fieldType) { + case BasicField.Number: + case BasicField.Decimal: + case BasicField.ShortText: + case BasicField.LongText: + case BasicField.HomeNo: + case BasicField.Dropdown: + case BasicField.Rating: + case BasicField.Nric: + case BasicField.Uen: + case BasicField.Date: + case BasicField.CountryRegion: + case BasicField.YesNo: + return response1.answer === response2.answer + + case BasicField.Attachment: { + const response2Answer = response2.answer as typeof response1.answer + return ( + response1.answer.answer === response2Answer.answer && + response1.answer.hasBeenScanned === response2Answer.hasBeenScanned + ) + } + case BasicField.Email: + case BasicField.Mobile: { + const response2Answer = response2.answer as typeof response1.answer + return ( + response1.answer.value === response2Answer.value && + response1.answer.signature === response2Answer.signature + ) + } + case BasicField.Table: { + const response2Answer = response2.answer as typeof response1.answer + return areArraysEqual( + response1.answer, + response2Answer, + (row1, row2) => + Object.keys(row1).length === Object.keys(row2).length && + Object.keys(row1).every( + (columnId) => row1[columnId] === row2[columnId], + ), + ) + } + case BasicField.Radio: { + if ('value' in response1.answer) { + const response2Answer = response2.answer as typeof response1.answer + return response1.answer.value === response2Answer.value + } else { + const response2Answer = response2.answer as typeof response1.answer + return response1.answer.othersInput === response2Answer.othersInput + } + } + case BasicField.Checkbox: { + const response2Answer = response2.answer as typeof response1.answer + return ( + areArraysEqual( + response1.answer.value, + response2Answer.value, + (value1, value2) => value1 === value2, + ) && response1.answer.othersInput === response2Answer.othersInput + ) + } + case BasicField.Children: { + const response2Answer = response2.answer as typeof response1.answer + return ( + areArraysEqual( + response1.answer.child, + response2Answer.child, + (child1, child2) => + areArraysEqual( + child1, + child2, + (value1, value2) => value1 === value2, + ), + ) && + areArraysEqual( + response1.answer.childFields, + response2Answer.childFields, + (attr1, attr2) => attr1 === attr2, + ) + ) + } + case BasicField.Section: + return true + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = response1 + return false + } + } +} diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts index 99dd9abd5d..43a621d219 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -424,9 +424,9 @@ const updateMultirespondentSubmission = async ( await submission.save() } catch (err) { logger.error({ - message: 'Encrypt submission save error', + message: 'Multirespondent submission save error', meta: { - action: 'onEncryptSubmissionFailure', + action: 'onMultirespondentSubmissionFailure', ...createReqMeta(req), }, error: err, @@ -491,8 +491,7 @@ export const handleMultirespondentSubmission = [ MultirespondentSubmissionMiddleware.validateMultirespondentSubmissionParams, MultirespondentSubmissionMiddleware.createFormsgAndRetrieveForm, MultirespondentSubmissionMiddleware.scanAndRetrieveAttachments, - // TODO(MRF/FRM-1592): Add validation for FieldResponsesV3 - // EncryptSubmissionMiddleware.validateStorageSubmission, + MultirespondentSubmissionMiddleware.validateMultirespondentSubmission, MultirespondentSubmissionMiddleware.encryptSubmission, submitMultirespondentForm, ] as ControllerHandler[] @@ -501,10 +500,10 @@ export const handleUpdateMultirespondentSubmission = [ CaptchaMiddleware.validateCaptchaParams, TurnstileMiddleware.validateTurnstileParams, ReceiverMiddleware.receiveMultirespondentSubmission, - MultirespondentSubmissionMiddleware.validateMultirespondentSubmissionParams, + MultirespondentSubmissionMiddleware.validateUpdateMultirespondentSubmissionParams, MultirespondentSubmissionMiddleware.createFormsgAndRetrieveForm, MultirespondentSubmissionMiddleware.scanAndRetrieveAttachments, - // EncryptSubmissionMiddleware.validateStorageSubmission, + MultirespondentSubmissionMiddleware.validateMultirespondentSubmission, MultirespondentSubmissionMiddleware.setCurrentWorkflowStep, MultirespondentSubmissionMiddleware.encryptSubmission, updateMultirespondentSubmission, diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts index 5c2ef9ba15..9f14d2b1bf 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts @@ -1,18 +1,25 @@ import { celebrate, Joi, Segments } from 'celebrate' import { NextFunction } from 'express' import { StatusCodes } from 'http-status-codes' -import { err, errAsync, ok, Result, ResultAsync } from 'neverthrow' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { BasicField, + FieldResponsesV3, + FormDto, FormResponseMode, SubmissionType, } from '../../../../../shared/types' +import { areFieldResponseV3sEqual } from '../../../../../shared/utils/response-v3' import { isDev } from '../../../../app/config/config' import { ParsedClearAttachmentResponseV3 } from '../../../../types/api' import { MultirespondentFormLoadedDto } from '../../../../types/api/multirespondent_submission' import formsgSdk from '../../../config/formsg-sdk' import { createLoggerWithLabel } from '../../../config/logger' +import { + getLogicUnitPreventingSubmitV3, + getVisibleFieldIdsV3, +} from '../../../utils/logic-adaptor' import { createReqMeta } from '../../../utils/request' import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { assertFormAvailable } from '../../form/admin-form/admin-form.utils' @@ -23,6 +30,7 @@ import { DownloadCleanFileFailedError, InvalidSubmissionTypeError, MaliciousFileDetectedError, + ProcessingError, VirusScanFailedError, } from '../submission.errors' import { @@ -34,7 +42,10 @@ import { mapRouteError, } from '../submission.utils' -import { checkFormIsMultirespondent } from './multirespondent-submission.service' +import { + checkFormIsMultirespondent, + getMultirespondentSubmission, +} from './multirespondent-submission.service' import { CreateFormsgAndRetrieveFormMiddlewareHandlerRequest, MultirespondentSubmissionMiddlewareHandlerRequest, @@ -45,74 +56,35 @@ import { const logger = createLoggerWithLabel(module) -export const validateMultirespondentSubmissionParams = celebrate({ - [Segments.BODY]: Joi.object({ - responses: Joi.object().pattern( - /^[a-fA-F0-9]{24}$/, - Joi.object({ - fieldType: Joi.string().valid(...Object.values(BasicField)), - //TODO(MRF/FRM-1592): Improve this validation, should match ParsedClearFormFieldResponseV3 - answer: Joi.required(), - }), - ), - responseMetadata: Joi.object({ - responseTimeMs: Joi.number(), - numVisibleFields: Joi.number(), +const multirespondentSubmissionBodySchema = Joi.object({ + responses: Joi.object().pattern( + /^[a-fA-F0-9]{24}$/, + Joi.object({ + fieldType: Joi.string().valid(...Object.values(BasicField)), + //TODO(MRF/FRM-1592): Improve this validation, should match ParsedClearFormFieldResponseV3 + answer: Joi.required(), }), - version: Joi.number().required(), + ), + responseMetadata: Joi.object({ + responseTimeMs: Joi.number(), + numVisibleFields: Joi.number(), }), + version: Joi.number().required(), }) -export const setCurrentWorkflowStep = async ( - req: ProcessedMultirespondentSubmissionHandlerRequest, - res: Parameters[1], - next: NextFunction, -) => { - const { formId, submissionId } = req.params - if (!submissionId) { - return errAsync(new InvalidSubmissionTypeError()) - } - const logMeta = { - action: 'setCurrentWorkflowStep', - submissionId, - formId, - ...createReqMeta(req), - } +export const validateMultirespondentSubmissionParams = celebrate({ + [Segments.BODY]: multirespondentSubmissionBodySchema, +}) - return ( - // Step 1: Retrieve the full form object. - FormService.retrieveFullFormById(formId) - //Step 2: Check whether form is archived. - .andThen((form) => assertFormAvailable(form).map(() => form)) - // Step 3: Check whether form is multirespondent mode. - .andThen(checkFormIsMultirespondent) - // Step 4: Is multirespondent mode form, retrieve submission data. - .andThen((form) => - getEncryptedSubmissionData(form.responseMode, formId, submissionId), - ) - // Step 6: Retrieve presigned URLs for attachments. - .map((submissionData) => { - if (submissionData.submissionType !== SubmissionType.Multirespondent) { - return errAsync(new InvalidSubmissionTypeError()) - } - // Increment previous submission's workflow step by 1 to get workflow step of current submission - req.body.workflowStep = submissionData.workflowStep + 1 - return next() - }) - .mapErr((error) => { - logger.error({ - message: 'Failure retrieving encrypted submission response', - meta: logMeta, - error, - }) +const updateMultirespondentSubmissionBodySchema = + multirespondentSubmissionBodySchema.append({ + submissionSecretKey: Joi.string().required(), + }) + +export const validateUpdateMultirespondentSubmissionParams = celebrate({ + [Segments.BODY]: updateMultirespondentSubmissionBodySchema, +}) - const { statusCode, errorMessage } = mapRouteError(error) - return res.status(statusCode).json({ - message: errorMessage, - }) - }) - ) -} /** * Creates formsg namespace in req.body and populates it with featureFlags, formDef and encryptedFormDef. */ @@ -324,6 +296,244 @@ export const scanAndRetrieveAttachments = async ( return next() } +export const validateMultirespondentSubmission = async ( + req: ProcessedMultirespondentSubmissionHandlerRequest, + res: Parameters[1], + next: NextFunction, +) => { + const { formId, submissionId } = req.params + + const logMeta = { + action: 'validateMultirespondentSubmission', + submissionId, + formId, + ...createReqMeta(req), + } + /** + * What types of fields are there? + * Visible Not visible + * Editable Regular field validation Not allowed + * Non-editable Not allowed / prev submiss Not allowed + * + * Initial submission: + * 1. Retrieve form object + * 2. Defined editable fields from workflow[0].edit. + * a. If no workflow, all fields are editable. + * 3. Get visible fields by logic + * 4. CHECK: no preventing submit + * 5. CHECK: response fields C visible fields + * 6. CHECK: response fields C editable fields + * 7. CHECK: for each field, validate by its rules + * + * Subsequent submissions: + * Identical to initial except in step 6, check that any response fields that + * were non-editable were indeed not edited (i.e. equality with previous submission) + */ + + return ( + // Step 0: Prepare by retrieving relevant reference data + okAsync(submissionId) + .andThen((submissionId) => + // Step 0a: If its an existing submission, use the reference data from + // the submission rather than the form + submissionId + ? getMultirespondentSubmission(submissionId).map((submission) => ({ + previousSubmission: { + encryptedContent: submission.encryptedContent, + version: submission.version, + }, + workflowStep: submission.workflowStep + 1, + workflow: submission.workflow, + form_fields: submission.form_fields, + form_logics: submission.form_logics, + })) + : okAsync({ + previousSubmission: undefined, + workflowStep: 0, + workflow: req.formsg.formDef.workflow, + form_fields: req.formsg.formDef.form_fields, + form_logics: req.formsg.formDef.form_logics, + }), + ) + .andThen( + ({ + previousSubmission, + workflowStep, + workflow, + form_fields, + form_logics, + }) => { + // Step 0b: Determine editable fields based on the workflow step, if it exists. + const editableFieldIds = ( + workflow && !!workflow[workflowStep] + ? workflow[workflowStep].edit + : form_fields.map((ff) => ff._id) + ).map(String) + + const formPropertiesForLogicComputation = { + _id: formId, + form_fields, + form_logics, + } as Pick + + // Step 0c: Get visible fields based on evaluation of logic + return getVisibleFieldIdsV3( + req.body.responses, + formPropertiesForLogicComputation, + ).andThen((visibleFieldIds) => + // Step 1: Check prevent submission logic + getLogicUnitPreventingSubmitV3( + req.body.responses, + formPropertiesForLogicComputation, + visibleFieldIds, + ) + .andThen((logicUnitPreventingSubmit) => + logicUnitPreventingSubmit + ? err( + new ProcessingError('Submission prevented by form logic'), + ) + : ok(undefined), + ) + .andThen(() => + // Step 2: Check that response fields C visible fields + Object.keys(req.body.responses).every((fieldId) => + visibleFieldIds.has(fieldId), + ) + ? ok(undefined) + : err( + new ProcessingError( + 'Attempted to submit response on a hidden field', + ), + ), + ) + .andThen(() => { + // Step 3: Match non-editable response fields to previous version + const nonEditableFieldIdsWithResponses = Object.keys( + req.body.responses, + ).filter((fieldId) => !editableFieldIds.includes(fieldId)) + + // If it's the first submission, just check that response fields C editable fields + if (!previousSubmission) { + return nonEditableFieldIdsWithResponses.length === 0 + ? ok(undefined) + : err( + new ProcessingError( + 'Attempted to submit response on a non-editable field', + ), + ) + } + + // If it's not the first submission, need to check that the responses match existing values from the DB + if (!req.body.submissionSecretKey) { + return err( + new ProcessingError('Submission secret key is required'), + ) + } + + const previousSubmissionDecryptedContent = + formsgSdk.cryptoV3.decryptFromSubmissionKey( + req.body.submissionSecretKey, + previousSubmission, + ) + + if (!previousSubmissionDecryptedContent) { + return err( + new ProcessingError('Unable to decrypt previous response'), + ) + } + + const previousResponses = + previousSubmissionDecryptedContent.responses as FieldResponsesV3 + + return Result.combine( + nonEditableFieldIdsWithResponses.map((fieldId) => + areFieldResponseV3sEqual( + req.body.responses[fieldId], + previousResponses[fieldId], + ) + ? ok(undefined) + : err( + new ProcessingError( + 'Submitted response on a non-editable field which did not match previous response', + ), + ), + ), + ).map(() => undefined) + }) + .andThen(() => + // TODO: Step 4: Validate each field content individually. + ok(undefined), + ), + ) + }, + ) + .map(() => next()) + .mapErr((error) => { + logger.error({ + message: 'Validation failed on incoming multirespondent submission', + meta: logMeta, + error, + }) + + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + ) +} + +export const setCurrentWorkflowStep = async ( + req: ProcessedMultirespondentSubmissionHandlerRequest, + res: Parameters[1], + next: NextFunction, +) => { + const { formId, submissionId } = req.params + if (!submissionId) { + return errAsync(new InvalidSubmissionTypeError()) + } + const logMeta = { + action: 'setCurrentWorkflowStep', + submissionId, + formId, + ...createReqMeta(req), + } + + return ( + // Step 1: Retrieve the full form object. + FormService.retrieveFullFormById(formId) + //Step 2: Check whether form is archived. + .andThen((form) => assertFormAvailable(form).map(() => form)) + // Step 3: Check whether form is multirespondent mode. + .andThen(checkFormIsMultirespondent) + // Step 4: Is multirespondent mode form, retrieve submission data. + .andThen((form) => + getEncryptedSubmissionData(form.responseMode, formId, submissionId), + ) + // Step 6: Retrieve presigned URLs for attachments. + .map((submissionData) => { + if (submissionData.submissionType !== SubmissionType.Multirespondent) { + return errAsync(new InvalidSubmissionTypeError()) + } + // Increment previous submission's workflow step by 1 to get workflow step of current submission + req.body.workflowStep = submissionData.workflowStep + 1 + return next() + }) + .mapErr((error) => { + logger.error({ + message: 'Failure retrieving encrypted submission response', + meta: logMeta, + error, + }) + + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + ) +} + /** * Encrypt submission content before saving to DB. */ diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts index f0ae7ea17e..ef1a04e275 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts @@ -1,12 +1,25 @@ -import { err, ok, Result } from 'neverthrow' +import mongoose from 'mongoose' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { FormResponseMode } from '../../../../../shared/types' import { + IMultirespondentSubmissionSchema, IPopulatedForm, IPopulatedMultirespondentForm, } from '../../../../types' +import { createLoggerWithLabel } from '../../../config/logger' +import { getMultirespondentSubmissionModel } from '../../../models/submission.server.model' +import { transformMongoError } from '../../../utils/handle-mongo-error' +import { DatabaseError } from '../../core/core.errors' import { isFormMultirespondent } from '../../form/form.utils' -import { ResponseModeError } from '../submission.errors' +import { + ResponseModeError, + SubmissionNotFoundError, +} from '../submission.errors' + +const logger = createLoggerWithLabel(module) + +const MultirespondentSubmission = getMultirespondentSubmissionModel(mongoose) export const checkFormIsMultirespondent = ( form: IPopulatedForm, @@ -20,3 +33,30 @@ export const checkFormIsMultirespondent = ( ), ) } + +export const getMultirespondentSubmission = ( + submissionId: string, +): ResultAsync< + IMultirespondentSubmissionSchema, + DatabaseError | SubmissionNotFoundError +> => + ResultAsync.fromPromise( + MultirespondentSubmission.findById(submissionId).exec(), + (error) => { + logger.error({ + message: + 'Error encountered while retrieving multirespondent submission', + meta: { + action: 'getMultirespondentSubmission', + submissionId, + }, + error, + }) + return transformMongoError(error) + }, + ).andThen((submission) => { + if (!submission) { + return errAsync(new SubmissionNotFoundError()) + } + return okAsync(submission) + }) diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts index 88d86fc8f7..f716339b7b 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts @@ -40,6 +40,7 @@ export type ProcessedMultirespondentSubmissionHandlerType = ControllerHandler< { formId: string; submissionId?: string }, SubmissionResponseDto | SubmissionErrorDto, Omit & { + submissionSecretKey?: string responses: ParsedClearFormFieldResponsesV3 }, { captchaResponse?: unknown; captchaType?: unknown } diff --git a/src/app/utils/logic-adaptor.ts b/src/app/utils/logic-adaptor.ts index 36c61accbe..1a033456de 100644 --- a/src/app/utils/logic-adaptor.ts +++ b/src/app/utils/logic-adaptor.ts @@ -1,10 +1,22 @@ -import { ok, Result } from 'neverthrow' +import { err, ok, Result } from 'neverthrow' -import { FormDto, PreventSubmitLogicDto } from '../../../shared/types' +import { LOGIC_MAP } from '../../../shared/modules/logic' +import { + BasicField, + FieldResponseAnswerMapV3, + FieldResponsesV3, + FormDto, + LogicableField, + PreventSubmitLogicDto, + RadioFieldResponsesV3, + SingleAnswerResponseV3, +} from '../../../shared/types' +import { isNonEmpty } from '../../../shared/utils/isNonEmpty' import { FieldIdSet, getLogicUnitPreventingSubmit as logicGetLogicUnitPreventingSubmit, getVisibleFieldIds as logicGetVisibleFieldIds, + type LogicFieldResponse, } from '../../../shared/utils/logic' import { FieldResponse, IFormDocument } from '../../types' import { ProcessingError } from '../modules/submission/submission.errors' @@ -31,3 +43,75 @@ export const getLogicUnitPreventingSubmit = ( ), ) } + +export const getVisibleFieldIdsV3 = ( + submission: FieldResponsesV3, + form: Pick, +): Result => + // Convert submission into a form understood by the shared function + fieldResponsesV3ToLogicFieldResponseTransformer(submission, form.form_fields) + // Call the shared logic evaluator + .map((responseData) => + logicGetVisibleFieldIds(responseData, form as unknown as FormDto), + ) + +export const getLogicUnitPreventingSubmitV3 = ( + submission: FieldResponsesV3, + form: Pick, + visibleFieldIds: FieldIdSet, +): Result => + fieldResponsesV3ToLogicFieldResponseTransformer( + submission, + form.form_fields, + ).map((responseData) => + logicGetLogicUnitPreventingSubmit(responseData, form, visibleFieldIds), + ) + +// Transformer functions + +type SingleAnswerLogicableField = Exclude + +const isLogicableField = (args: { + fieldType: BasicField + input: FieldResponseAnswerMapV3 +}): args is + | { + fieldType: SingleAnswerLogicableField + input: SingleAnswerResponseV3 + } + | { + fieldType: BasicField.Radio + input: RadioFieldResponsesV3 + } => [...LOGIC_MAP.keys()].includes(args.fieldType) + +const isNotLogicableField = (args: { + fieldType: BasicField + input: FieldResponseAnswerMapV3 +}): args is { + fieldType: Exclude + input: FieldResponseAnswerMapV3 +} => !isLogicableField(args) + +const fieldResponsesV3ToLogicFieldResponseTransformer = ( + submission: FieldResponsesV3, + form_fields: FormDto['form_fields'], +): Result => + Result.combine( + form_fields.map((ff) => { + const input = submission[ff._id]?.answer + if (!input) return ok(null) + const fieldTypeAndInput = { + fieldType: ff.fieldType, + input, + } + // Type narrowing to help the typechecker along with complex if-else types + if (isNotLogicableField(fieldTypeAndInput)) { + return ok({ _id: ff._id, fieldType: fieldTypeAndInput.fieldType }) + } else if (isLogicableField(fieldTypeAndInput)) { + return ok({ _id: ff._id, ...fieldTypeAndInput }) + } else { + // This should never happen! + return err(new ProcessingError()) + } + }), + ).map((responseData) => responseData.filter(isNonEmpty))