diff --git a/frontend/src/features/admin-form/AdminFormResultsResponsesPage.stories.tsx b/frontend/src/features/admin-form/AdminFormResultsResponsesPage.stories.tsx index 3d095d1640..c40f0fae7d 100644 --- a/frontend/src/features/admin-form/AdminFormResultsResponsesPage.stories.tsx +++ b/frontend/src/features/admin-form/AdminFormResultsResponsesPage.stories.tsx @@ -9,6 +9,7 @@ import { createFormBuilderMocks, getAdminFormCollaborators, getAdminFormSubmissions, + getMultiRespondentSubmissionMetadataResponse, getStorageSubmissionMetadataResponse, } from '~/mocks/msw/handlers/admin-form' import { getUser } from '~/mocks/msw/handlers/user' @@ -185,6 +186,24 @@ StorageFormLoading.parameters = { ], } +export const MultiRespondentFormUnlocked = Template.bind({}) +MultiRespondentFormUnlocked.parameters = { + msw: [ + ...createFormBuilderMocks( + { + responseMode: FormResponseMode.Multirespondent, + publicKey: MOCK_KEYPAIR.publicKey, + }, + 0, + ), + getAdminFormSubmissions({ override: 5 }), + getMultiRespondentSubmissionMetadataResponse(), + getUser(), + getAdminFormCollaborators(), + ], +} +MultiRespondentFormUnlocked.play = StorageFormUnlocked.play + export const Loading = Template.bind({}) Loading.parameters = { msw: [ diff --git a/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts b/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts index 6bfbb5f78b..443266be3e 100644 --- a/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts +++ b/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts @@ -138,6 +138,10 @@ export const getDecryptedSubmissionById = async ({ encryptedSubmission.submissionType === SubmissionType.Encrypt ? encryptedSubmission.payment : undefined, + mrf: + encryptedSubmission.submissionType === SubmissionType.Multirespondent + ? encryptedSubmission.mrfMeta + : undefined, responses, mrfVersion, } diff --git a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx index 54719d1b86..66091c045f 100644 --- a/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx +++ b/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx @@ -22,7 +22,16 @@ import { useAdminForm } from '~features/admin-form/common/queries' import { FormActivationSvg } from '~features/admin-form/settings/components/FormActivationSvg' import { useUser } from '~features/user/queries' +import { + getCurrentStepString, + getStatusFromWorkflowStatus, +} from '../common/utils/mrfSubmissionView' import { SecretKeyVerification } from '../components/SecretKeyVerification' +import { + MRF_CURRENT_STEP_LABEL, + MRF_FIRST_STEP_TIMESTAMP_LABEL, + MRF_STATUS_LABEL, +} from '../constants' import { useStorageResponsesContext } from '../ResponsesPage/storage' import { DecryptedRow } from './DecryptedRow' @@ -84,6 +93,8 @@ export const IndividualResponsePage = (): JSX.Element => { const { data: form } = useAdminForm() + const isMrf = form?.responseMode === FormResponseMode.Multirespondent + const { user } = useUser() const { secretKey } = useStorageResponsesContext() const { data, isLoading, isError } = useIndividualSubmission() @@ -156,8 +167,42 @@ export const IndividualResponsePage = (): JSX.Element => { isLoading={isLoading} isError={isError} /> + {isMrf ? ( + <> + + + + ) : null} + + [] = [ +const PendingBadge = () => ( + + + Pending + +) + +const CompletedBadge = () => ( + + + Completed + +) + +const BASE_RESPONSE_TABLE_COLUMNS: Column[] = [ { Header: '#', accessor: 'number', - minWidth: 80, // minWidth is only used as a limit for resizing width: 80, // width is used for both the flex-basis and flex-grow + minWidth: 80, // minWidth is only used as a limit for resizing maxWidth: 100, // maxWidth is only used as a limit for resizing }, { Header: 'Response ID', accessor: 'refNo', - minWidth: 300, width: 300, - maxWidth: 240, // maxWidth is only used as a limit for resizing + minWidth: 300, + maxWidth: 300, }, { Header: 'Timestamp', accessor: 'submissionTime', - minWidth: 250, width: 250, + minWidth: 250, disableResizing: true, }, ] + const PAYMENT_COLUMNS: Column[] = [ { Header: 'Email', @@ -105,8 +153,58 @@ const PAYMENT_COLUMNS: Column[] = [ }, ] +const MRF_RESPONSE_TABLE_COLUMNS: Column[] = [ + { + Header: '#', + accessor: 'number', + width: 80, + minWidth: 80, + maxWidth: 100, + }, + { + Header: 'Response ID', + accessor: 'refNo', + width: 300, + minWidth: 300, + maxWidth: 300, + }, + { + Header: MRF_STATUS_LABEL, + accessor: ({ mrf }) => { + if (!mrf?.workflowStatus) { + return '' + } + if (mrf.workflowStatus === WorkflowStatus.PENDING) { + return + } + return + }, + width: 200, + minWidth: 180, + maxWidth: 220, + }, + { + Header: MRF_CURRENT_STEP_LABEL, + accessor: ({ mrf }) => + getCurrentStepString( + mrf?.workflowCurrentStepNumber, + mrf?.workflowNumTotalSteps, + ), + width: 200, + minWidth: 180, + maxWidth: 220, + }, + { + Header: MRF_FIRST_STEP_TIMESTAMP_LABEL, + accessor: 'submissionTime', + width: 250, + minWidth: 250, + disableResizing: true, + }, +] + const PAYMENT_RESPONSE_TABLE_COLUMNS = - RESPONSE_TABLE_COLUMNS.concat(PAYMENT_COLUMNS) + BASE_RESPONSE_TABLE_COLUMNS.concat(PAYMENT_COLUMNS) export const ResponsesTable = () => { const { data: form } = useAdminForm() @@ -114,6 +212,8 @@ export const ResponsesTable = () => { form?.responseMode === FormResponseMode.Encrypt ? form.payments_field.enabled : false + const isMultiRespondentForm = + form?.responseMode === FormResponseMode.Multirespondent const { currentPage: currentPage1Indexed, @@ -138,6 +238,16 @@ export const ResponsesTable = () => { } }, [filteredMetadata, metadata, submissionId]) + const columns = useMemo(() => { + if (isMultiRespondentForm) { + return MRF_RESPONSE_TABLE_COLUMNS + } + if (isPaymentsForm) { + return PAYMENT_RESPONSE_TABLE_COLUMNS + } + return BASE_RESPONSE_TABLE_COLUMNS + }, [isMultiRespondentForm, isPaymentsForm]) + const { prepareRow, getTableProps, @@ -147,9 +257,7 @@ export const ResponsesTable = () => { gotoPage, } = useTable( { - columns: isPaymentsForm - ? PAYMENT_RESPONSE_TABLE_COLUMNS - : RESPONSE_TABLE_COLUMNS, + columns, data: metadataToUse, // Server side pagination. manualPagination: true, diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/types.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/types.ts index 1d0f284783..16171dc27e 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/types.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/types.ts @@ -2,6 +2,8 @@ import { FormField } from '@opengovsg/formsg-sdk/dist/types' import { Remote } from 'comlink' import { SetRequired } from 'type-fest' +import { SubmissionMrfMetadata } from '~shared/types' + import { CsvRecord } from './utils/CsvRecord.class' import { DecryptionWorkerApi } from './worker/decryption.worker' @@ -23,6 +25,7 @@ export type CsvRecordData = FormField export type DecryptedSubmissionData = { created: string submissionId: string + mrfMeta?: SubmissionMrfMetadata record: CsvRecordData[] } diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts index 31ad9f1013..632d75a471 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts @@ -48,6 +48,8 @@ export type DownloadEncryptedParams = EncryptedResponsesStreamParams & { secretKey: string // Number of responses to expect to download. responsesCount: number + // Used to determine if we should add MRF related columns to the CSV. + isMrf: boolean } interface UseDecryptionWorkersProps { onProgress: (progress: number) => void @@ -104,6 +106,7 @@ const useDecryptionWorkers = ({ secretKey, endDate, startDate, + isMrf, }: DownloadEncryptedParams) => { if (!adminForm || !responsesCount) { return Promise.resolve({ @@ -158,6 +161,7 @@ const useDecryptionWorkers = ({ const csvGenerator = new EncryptedResponseCsvGenerator( responsesCount, NUM_OF_METADATA_ROWS, + isMrf, ) const stream = await getEncryptedResponsesStream( @@ -382,6 +386,7 @@ const useDecryptionWorkers = ({ secretKey, endDate, startDate, + isMrf, }: DownloadEncryptedParams) => { if (!adminForm || !responsesCount) { return Promise.resolve({ @@ -435,6 +440,7 @@ const useDecryptionWorkers = ({ const csvGenerator = new EncryptedResponseCsvGenerator( responsesCount, NUM_OF_METADATA_ROWS, + isMrf, ) const stream = await getEncryptedResponsesStream( diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts index 2d550aa81a..44c48d84f1 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash' -import { SubmissionPaymentDto } from '~shared/types' +import { SubmissionMrfMetadata, SubmissionPaymentDto } from '~shared/types' import { getPaymentDataView } from '~features/admin-form/responses/common/utils/getPaymentDataView' @@ -34,6 +34,7 @@ export class CsvRecord { public form: string, public hostOrigin: string, public paymentData?: SubmissionPaymentDto, + public mrfData?: SubmissionMrfMetadata, ) { this.#statusMessage = status this.#record = [] @@ -135,6 +136,7 @@ export class CsvRecord { this.submissionData = { created: this.created, submissionId: this.id, + mrfMeta: this.mrfData, record: output, } } diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.test.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.test.ts index 9529a50b06..d2fa5cbc91 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.test.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.test.ts @@ -3,6 +3,18 @@ import { stringify } from 'csv-string' import { formatInTimeZone } from 'date-fns-tz' import { SetOptional } from 'type-fest' +import { WorkflowStatus } from '~shared/types/submission' + +import { + getCurrentStepString, + MRF_STATUS, +} from '~features/admin-form/responses/common/utils/mrfSubmissionView' +import { + MRF_CURRENT_STEP_LABEL, + MRF_FIRST_STEP_TIMESTAMP_LABEL, + MRF_STATUS_LABEL, +} from '~features/admin-form/responses/constants' + import { CsvRecordData, DecryptedSubmissionData } from '../../types' import { DisplayedResponseWithoutAnswer, @@ -101,6 +113,7 @@ describe('EncryptedResponseCsvGenerator', () => { generator = new EncryptedResponseCsvGenerator( mockExpectedNumberOfRecords, mockNumOfMetaDataRows, + false, ) }) @@ -360,6 +373,54 @@ describe('EncryptedResponseCsvGenerator', () => { expect(generator.hasBeenProcessed).toBe(true) }) + describe('mrf form submissions', () => { + it('should include header and data columns for mrf metadata', () => { + // Arrange + const mrfGenerator = new EncryptedResponseCsvGenerator(1, 0, true) + const mockDecryptedRecord = [generateRecord(1)] + const mockRecord = { + record: mockDecryptedRecord, + created: mockCreatedEarly, + submissionId: 'mockSubmissionId', + mrfMeta: { + workflowStatus: WorkflowStatus.REJECTED, + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 3, + }, + } + mrfGenerator.addRecord(mockRecord) + + // Act + mrfGenerator.process() + + // Assert + // Should have 1 header row and 1 submission row + expect(mrfGenerator.records.length).toEqual(2 + BOM_LENGTH) + const expectedHeaderRow = stringify([ + 'Response ID', + MRF_STATUS_LABEL, + MRF_CURRENT_STEP_LABEL, + MRF_FIRST_STEP_TIMESTAMP_LABEL, + mockDecryptedRecord[0].question, + ]) + const expectedSubmissionRow = stringify([ + mockRecord.submissionId, + MRF_STATUS.COMPLETED, + getCurrentStepString( + mockRecord.mrfMeta.workflowCurrentStepNumber, + mockRecord.mrfMeta.workflowNumTotalSteps, + ), + getFormattedDate(mockRecord.created), + mockDecryptedRecord[0].answer, + ]) + expect(mrfGenerator.records).toEqual([ + UTF8_BYTE_ORDER_MARK, + expectedHeaderRow, + expectedSubmissionRow, + ]) + }) + }) + describe('submissions with only answer key', () => { it('should handle a single submission', () => { // Arrange diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.ts index 9a042b19fc..e7e0ad9428 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/EncryptedResponseCsvGenerator/EncryptedResponseCsvGenerator.ts @@ -4,7 +4,17 @@ import type { Dictionary } from 'lodash' import { keyBy } from 'lodash' import type { Merge } from 'type-fest' +import { + MRF_CURRENT_STEP_LABEL, + MRF_FIRST_STEP_TIMESTAMP_LABEL, + MRF_STATUS_LABEL, +} from '~features/admin-form/responses/constants' + import { CsvGenerator } from '../../../../common/utils' +import { + getCurrentStepString, + getStatusFromWorkflowStatus, +} from '../../../../common/utils/mrfSubmissionView' import type { DecryptedSubmissionData } from '../../types' import type { Response } from '../csv-response-classes' import { getDecryptedResponseInstance } from '../getDecryptedResponseInstance' @@ -15,14 +25,28 @@ type UnprocessedRecord = Merge< { record: Dictionary } > +const MRF_CSV_HEADERS = [ + 'Response ID', + MRF_STATUS_LABEL, + MRF_CURRENT_STEP_LABEL, + MRF_FIRST_STEP_TIMESTAMP_LABEL, +] + +const BASE_CSV_HEADERS = ['Response ID', 'Timestamp'] + export class EncryptedResponseCsvGenerator extends CsvGenerator { hasBeenProcessed: boolean hasBeenSorted: boolean fieldIdToQuestion: Map fieldIdToNumCols: Record unprocessed: UnprocessedRecord[] + isMrf: boolean - constructor(expectedNumberOfRecords: number, numOfMetaDataRows: number) { + constructor( + expectedNumberOfRecords: number, + numOfMetaDataRows: number, + isMrf: boolean, + ) { super(expectedNumberOfRecords, numOfMetaDataRows) this.hasBeenProcessed = false @@ -30,6 +54,7 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { this.fieldIdToQuestion = new Map() this.fieldIdToNumCols = {} this.unprocessed = [] + this.isMrf = isMrf } /** @@ -43,7 +68,11 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { * Extracts information from input record, rearranges record and then adds an UnprocessedRecord to `this.unprocessed` * @throws Error when trying to convert record into a response instance. Should be caught in submissions client factory. */ - addRecord({ record, created, submissionId }: DecryptedSubmissionData): void { + addRecord({ + record, + created, + ...otherSubmissionProperties + }: DecryptedSubmissionData): void { // First pass, create object with { [fieldId]: question } from // decryptedContent to get all the questions. const fieldRecords = record.map((content) => { @@ -75,8 +104,8 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { // Rearrange record to be an object identified by field ID. this.unprocessed.push({ created, - submissionId, record: keyBy(fieldRecords, (fieldRecord) => fieldRecord.id), + ...otherSubmissionProperties, }) } @@ -89,9 +118,12 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { if (this.hasBeenProcessed) return // Create a header row in CSV using the fieldIdToQuestion map. - const headers = ['Response ID', 'Timestamp'] + // NOTE: de-structuring is necessary to avoid mutating the array referenced by the `headers` array below. + // See: https://github.com/opengovsg/FormSG/pull/7965#discussion_r1883954194. + const headers = this.isMrf ? [...MRF_CSV_HEADERS] : [...BASE_CSV_HEADERS] this.fieldIdToQuestion.forEach((value, fieldId) => { for (let i = 0; i < this.fieldIdToNumCols[fieldId]; i++) { + // TODO: (Code quality) Refactor to avoid mutating the `headers` array. headers.push(value.question) } }) @@ -100,6 +132,20 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { // Craft a new csv row for each unprocessed record // O(qn), where q = number of unique questions, n = number of submissions. this.unprocessed.forEach((up) => { + const row = [up.submissionId] + + if (this.isMrf) { + const mrfSubmissionStatus = getStatusFromWorkflowStatus( + up.mrfMeta?.workflowStatus, + ) + row.push(mrfSubmissionStatus) + const currentStepString = getCurrentStepString( + up.mrfMeta?.workflowCurrentStepNumber, + up.mrfMeta?.workflowNumTotalSteps, + ) + row.push(currentStepString) + } + const formattedDate = isValid(parseISO(up.created)) ? formatInTimeZone( up.created, @@ -107,7 +153,7 @@ export class EncryptedResponseCsvGenerator extends CsvGenerator { 'dd MMM yyyy hh:mm:ss a', ) : up.created - const row = [up.submissionId, formattedDate] + row.push(formattedDate) this.fieldIdToQuestion.forEach((_question, fieldId) => { const numCols = this.fieldIdToNumCols[fieldId] diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts index 86ac630891..3fb7ac1730 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts @@ -13,15 +13,6 @@ import { } from '../types' import { CsvRecord } from '../utils/CsvRecord.class' -// Fixes issue raised at https://stackoverflow.com/questions/66472945/referenceerror-refreshreg-is-not-defined -// Something to do with babel-loader. -if (import.meta.env.NODE_ENV !== 'production') { - // eslint-disable-next-line - ;(global as any).$RefreshReg$ = () => {} - // eslint-disable-next-line - ;(global as any).$RefreshSig$ = () => () => {} -} - const queue = new PQueue({ concurrency: 1 }) /** @@ -99,6 +90,14 @@ async function decryptIntoCsv( submission.submissionType === SubmissionType.Encrypt ? submission.payment : undefined, + submission.submissionType === SubmissionType.Multirespondent + ? { + workflowStatus: submission.mrfMeta.workflowStatus, + workflowCurrentStepNumber: + submission.mrfMeta.workflowCurrentStepNumber, + workflowNumTotalSteps: submission.mrfMeta.workflowNumTotalSteps, + } + : undefined, ) try { let decryptedSubmission, submissionSecretKey @@ -204,7 +203,7 @@ async function decryptIntoCsv( } catch (error) { csvRecord.setStatus(CsvRecordStatus.Error, 'Decryption Error') } - } catch (err) { + } catch (error) { csvRecord = new CsvRecord( CsvRecordStatus.Error, formatInTimeZone(new Date(), 'Asia/Singapore', 'dd MMM yyyy hh:mm:ss z'), diff --git a/frontend/src/features/admin-form/responses/common/utils/mrfSubmissionView.ts b/frontend/src/features/admin-form/responses/common/utils/mrfSubmissionView.ts new file mode 100644 index 0000000000..2543c60d08 --- /dev/null +++ b/frontend/src/features/admin-form/responses/common/utils/mrfSubmissionView.ts @@ -0,0 +1,31 @@ +import { WorkflowStatus } from '~shared/types' + +export enum MRF_STATUS { + COMPLETED = 'Completed', + PENDING = 'Pending', +} + +/** Gets the business friendly string for current step of workflow. */ +export const getCurrentStepString = ( + workflowCurrentStepNumber: number | undefined, + workflowNumTotalSteps: number | undefined, +) => + workflowCurrentStepNumber && workflowNumTotalSteps + ? `Step ${workflowCurrentStepNumber} of ${workflowNumTotalSteps}` + : '' + +/** Gets the business friendly string for MRF submission status. */ +export const getStatusFromWorkflowStatus = ( + workflowStatus: WorkflowStatus | undefined, +): MRF_STATUS | '' => { + switch (workflowStatus) { + case WorkflowStatus.COMPLETED: + case WorkflowStatus.APPROVED: + case WorkflowStatus.REJECTED: + return MRF_STATUS.COMPLETED + case WorkflowStatus.PENDING: + return MRF_STATUS.PENDING + case undefined: + return '' + } +} diff --git a/frontend/src/features/admin-form/responses/constants.ts b/frontend/src/features/admin-form/responses/constants.ts new file mode 100644 index 0000000000..62fa96b634 --- /dev/null +++ b/frontend/src/features/admin-form/responses/constants.ts @@ -0,0 +1,3 @@ +export const MRF_FIRST_STEP_TIMESTAMP_LABEL = 'Submission time of first step' +export const MRF_CURRENT_STEP_LABEL = 'Current step' +export const MRF_STATUS_LABEL = 'Status' diff --git a/frontend/src/mocks/msw/handlers/admin-form/form.ts b/frontend/src/mocks/msw/handlers/admin-form/form.ts index b3b21a9c3c..2646c4812e 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/form.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/form.ts @@ -30,7 +30,10 @@ import { } from '~shared/types/form/form' import { FormLogoState } from '~shared/types/form/form_logo' import { DateString } from '~shared/types/generic' -import { SubmissionMetadataList } from '~shared/types/submission' +import { + SubmissionMetadataList, + WorkflowStatus, +} from '~shared/types/submission' import { UserDto } from '~shared/types/user' import { insertAt, reorder } from '~shared/utils/immutable-array-fns' @@ -331,6 +334,63 @@ export const MOCK_FORM_FIELDS: FormFieldDto[] = [ }, ] +const DEFAULT_MULTIRESPONDENT_METADATA = [ + [ + { + number: 1, + refNo: '62a8a7476f4f3e005bcd5ab7', + submissionTime: '14th Jun 2022, 11:20:39 pm', + mrf: { + workflowCurrentStepNumber: 1, + workflowNumTotalSteps: 5, + workflowStatus: WorkflowStatus.PENDING, + }, + }, + { + number: 2, + refNo: '62a8a7476f4f3e005bcd5ab8', + submissionTime: '14th Jun 2022, 11:21:39 pm', + mrf: { + workflowCurrentStepNumber: 3, + workflowNumTotalSteps: 3, + workflowStatus: WorkflowStatus.APPROVED, + }, + }, + { + number: 3, + refNo: '62a8a7476f4f3e005bcd5ab9', + submissionTime: '14th Jun 2022, 11:22:39 pm', + mrf: { + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 3, + workflowStatus: WorkflowStatus.REJECTED, + }, + }, + { + number: 4, + refNo: '62a8a7476f4f3e005bcd5ac0', + submissionTime: '14th Jun 2022, 11:23:39 pm', + mrf: { + workflowCurrentStepNumber: 4, + workflowNumTotalSteps: 4, + workflowStatus: WorkflowStatus.COMPLETED, + }, + }, + // simulates a submission prior to https://github.com/opengovsg/FormSG/pull/7965 + // which the workflowStatus cannot be determined as submittedSteps is undefined. + { + number: 5, + refNo: '62a8a7476f4f3e005bcd5ac1', + submissionTime: '14th Jun 2022, 11:24:39 pm', + mrf: { + workflowCurrentStepNumber: 1, + workflowNumTotalSteps: 4, + workflowStatus: undefined, + }, + }, + ], +] + const DEFAULT_STORAGE_METADATA = [ [ { @@ -784,7 +844,7 @@ export const getStorageSubmissionMetadataResponse = ( ctx.json( merge( { - count: 39, + count: DEFAULT_STORAGE_METADATA.flat().length, metadata: DEFAULT_STORAGE_METADATA[pageNum - 1], }, props, @@ -795,6 +855,31 @@ export const getStorageSubmissionMetadataResponse = ( ) } +export const getMultiRespondentSubmissionMetadataResponse = ( + props: Partial = {}, + delay: number | 'infinite' | 'real' = 0, +) => { + return rest.get( + '/api/v3/admin/forms/:formId/submissions/metadata', + (req, res, ctx) => { + const pageNum = parseInt(req.url.searchParams.get('page') ?? '1') + return res( + ctx.delay(delay), + ctx.status(200), + ctx.json( + merge( + { + count: DEFAULT_MULTIRESPONDENT_METADATA.flat().length, + metadata: DEFAULT_MULTIRESPONDENT_METADATA[pageNum - 1], + }, + props, + ), + ), + ) + }, + ) +} + export const createLogic = (delay?: number | 'infinite') => { return rest.post( '/api/v3/admin/forms/:formId/logic', diff --git a/shared/types/submission.ts b/shared/types/submission.ts index aca2f5a74c..0b0cff4e11 100644 --- a/shared/types/submission.ts +++ b/shared/types/submission.ts @@ -80,6 +80,39 @@ export type StorageModeSubmissionBase = z.infer< * Multirespondent submission typings as stored in the database. */ +export enum WorkflowStatus { + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', +} + +export const ApprovalStatus = z.enum([ + WorkflowStatus.APPROVED, + WorkflowStatus.REJECTED, +]) + +const SubmittedNonApprovalStep = z.object({ + isApproval: z.literal(false), + submittedAt: z.string().datetime({ precision: 3 }), +}) + +export type SubmittedNonApprovalStep = z.infer + +const SubmittedApprovalStep = SubmittedNonApprovalStep.extend({ + isApproval: z.literal(true), + status: ApprovalStatus, +}) + +export type SubmittedApprovalStep = z.infer + +export const SubmittedStep = z.discriminatedUnion('isApproval', [ + SubmittedApprovalStep, + SubmittedNonApprovalStep, +]) + +export type SubmittedStep = z.infer + export const MultirespondentSubmissionBase = SubmissionBase.extend({ // Store the form fields and logic here, to use as reference for future // submitters. Don't bother to validate since this is injected by the backend. @@ -95,6 +128,7 @@ export const MultirespondentSubmissionBase = SubmissionBase.extend({ version: z.number(), workflowStep: z.number(), mrfVersion: z.number().optional(), + submittedSteps: z.array(SubmittedStep).optional(), }) export type MultirespondentSubmissionBase = z.infer< @@ -158,6 +192,8 @@ export type MultirespondentSubmissionDto = SubmissionDtoBase & { version: number mrfVersion: number + + mrfMeta: SubmissionMrfMetadata } export type SubmissionDto = @@ -193,6 +229,11 @@ export const MultirespondentSubmissionStreamDto = attachmentMetadata: z.record(z.string()), _id: SubmissionId, created: DateString, + mrfMeta: z.object({ + workflowCurrentStepNumber: z.number(), + workflowNumTotalSteps: z.number(), + workflowStatus: z.nativeEnum(WorkflowStatus).optional(), + }), }) export type MultirespondentSubmissionStreamDto = z.infer< @@ -213,12 +254,21 @@ export type SubmissionPaymentMetadata = { email: string } | null +export type SubmissionMrfMetadata = + | { + workflowCurrentStepNumber: number + workflowNumTotalSteps: number + workflowStatus: WorkflowStatus | undefined // `undefined` is due to submissions before this PR not storing this value + } + | undefined + export type SubmissionMetadata = { number: number refNo: SubmissionId /** Not a DateString, format is `Do MMM YYYY, h:mm:ss a` */ submissionTime: string payments: SubmissionPaymentMetadata + mrf: SubmissionMrfMetadata } export type SubmissionMetadataList = { diff --git a/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts b/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts index 663326d672..63c855c9f1 100644 --- a/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts +++ b/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts @@ -3,7 +3,13 @@ import { ObjectId } from 'bson' import { pick, times } from 'lodash' import moment from 'moment-timezone' import mongoose from 'mongoose' -import { SubmissionMetadata, SubmissionType } from 'shared/types' +import { + BasicField, + SubmissionMetadata, + SubmissionType, + WorkflowStatus, + WorkflowType, +} from 'shared/types' import getSubmissionModel, { getEmailSubmissionModel, @@ -24,6 +30,34 @@ describe('Multirespondent Submission Model', () => { const MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY = 'This is an encrypted secret key' const MOCK_ENCRYPTED_CONTENT = 'abcdefg encryptedContent' + const YES_NO_FIELD = { + _id: 'yes_no_field_id', + title: 'Yes or No', + description: '', + required: true, + disabled: false, + fieldType: BasicField.YesNo, + } + const WORKFLOW_STEP_1 = { + _id: 'step_1_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [], + } + const WORKFLOW_STEP_2 = { + _id: 'step_2_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [YES_NO_FIELD._id], + } + const WORKFLOW_APPROVAL_STEP = { + _id: 'approval_step_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [YES_NO_FIELD._id], + approval_field: YES_NO_FIELD._id, + } + describe('Statics', () => { describe('findSingleMetadata', () => { it('should return submission metadata', async () => { @@ -34,15 +68,26 @@ describe('Multirespondent Submission Model', () => { const validSubmission = await MultirespondentSubmission.create({ form: validFormId, submissionType: SubmissionType.Multirespondent, - form_fields: [], + form_fields: [YES_NO_FIELD, WORKFLOW_APPROVAL_STEP], form_logics: [], - workflow: [], + workflow: [WORKFLOW_STEP_1, WORKFLOW_APPROVAL_STEP], submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: createdDate, - workflowStep: 0, + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: true, + status: WorkflowStatus.REJECTED, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + ], }) // Act @@ -59,6 +104,11 @@ describe('Multirespondent Submission Model', () => { submissionTime: moment(createdDate) .tz('Asia/Singapore') .format('Do MMM YYYY, h:mm:ss a'), + mrf: { + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 2, + workflowStatus: WorkflowStatus.REJECTED, + }, } expect(result).toEqual(expected) }) @@ -118,13 +168,19 @@ describe('Multirespondent Submission Model', () => { submissionType: SubmissionType.Multirespondent, form_fields: [], form_logics: [], - workflow: [], + workflow: [WORKFLOW_STEP_1], submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], workflowStep: 0, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + ], }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -146,6 +202,11 @@ describe('Multirespondent Submission Model', () => { submissionTime: moment(data.created) .tz('Asia/Singapore') .format('Do MMM YYYY, h:mm:ss a'), + mrf: { + workflowCurrentStepNumber: 1, + workflowNumTotalSteps: 1, + workflowStatus: WorkflowStatus.COMPLETED, + }, })) .reverse(), } @@ -161,13 +222,24 @@ describe('Multirespondent Submission Model', () => { submissionType: SubmissionType.Multirespondent, form_fields: [], form_logics: [], - workflow: [], + workflow: [WORKFLOW_STEP_1, WORKFLOW_APPROVAL_STEP], submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], - workflowStep: 0, + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: true, + status: WorkflowStatus.APPROVED, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + ], }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -194,6 +266,11 @@ describe('Multirespondent Submission Model', () => { submissionTime: moment(secondSubmission.created) .tz('Asia/Singapore') .format('Do MMM YYYY, h:mm:ss a'), + mrf: { + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 2, + workflowStatus: WorkflowStatus.APPROVED, + }, }, ], } @@ -209,13 +286,23 @@ describe('Multirespondent Submission Model', () => { submissionType: SubmissionType.Multirespondent, form_fields: [], form_logics: [], - workflow: [], + workflow: [WORKFLOW_STEP_1, WORKFLOW_STEP_2], submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], - workflowStep: 0, + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: false, + submittedAt: '2024-01-02T00:00:00.000Z', + }, + ], }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -242,6 +329,11 @@ describe('Multirespondent Submission Model', () => { submissionTime: moment(latestSubmission.created) .tz('Asia/Singapore') .format('Do MMM YYYY, h:mm:ss a'), + mrf: { + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 2, + workflowStatus: WorkflowStatus.COMPLETED, + }, }, ], } @@ -332,6 +424,8 @@ describe('Multirespondent Submission Model', () => { 'encryptedContent', 'submissionType', 'version', + 'submittedSteps', + 'workflowStep', ) // Native-ify arrays as mongoose documents contain a mongoose-specific array type. expectedSubmission.form_fields = JSON.parse( @@ -417,6 +511,7 @@ describe('Multirespondent Submission Model', () => { 'submissionType', 'version', 'workflowStep', + 'submittedSteps', ) expect(actual).not.toBeNull() expect(actual?.toJSON()).toEqual(expected) diff --git a/src/app/models/__tests__/submission.server.model.spec.ts b/src/app/models/__tests__/submission.server.model.spec.ts index d0da0a1512..0a03aea507 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -7,7 +7,9 @@ import mongoose from 'mongoose' import getSubmissionModel, { getEmailSubmissionModel, getEncryptSubmissionModel, + getMultirespondentSubmissionModel, } from 'src/app/models/submission.server.model' +import { buildMrfMetadata } from 'src/app/modules/submission/submission.utils' import { BasicField, @@ -29,6 +31,7 @@ const MockDns = jest.mocked(dns) const Submission = getSubmissionModel(mongoose) const EncryptedSubmission = getEncryptSubmissionModel(mongoose) +const MultirespondentSubmission = getMultirespondentSubmissionModel(mongoose) const EmailSubmission = getEmailSubmissionModel(mongoose) const PaymentSubmission = getPaymentModel(mongoose) @@ -91,6 +94,7 @@ describe('Submission Model', () => { field: new ObjectId(), }, ], + submittedSteps: [], submissionPublicKey: 'This is a public key', encryptedSubmissionSecretKey: 'This is an encrypted secret key', encryptedContent: MOCK_ENCRYPTED_CONTENT, @@ -570,6 +574,141 @@ describe('Submission Model', () => { expect(actualResult).toEqual([]) }) }) + + describe('findSingleMetadata', () => { + it('should not return mrf metadata for storage mode form', async () => { + // Arrange + const formId = new ObjectId() + const submission = await EncryptedSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 1, + authType: FormAuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + // Act + const result = await EncryptedSubmission.findSingleMetadata( + String(formId), + String(submission._id), + ) + + // Assert + expect(result?.mrf).toBeUndefined() + }) + + it('should return mrf metadata for an mrf form', async () => { + // Arrange + const formId = new ObjectId() + const workflowId = new ObjectId() + const fieldId = new ObjectId() + const submission = await MultirespondentSubmission.create({ + form: formId, + submissionType: SubmissionType.Multirespondent, + form_fields: [{ _id: fieldId, fieldType: BasicField.ShortText }], + form_logics: [], + workflow: [ + { _id: workflowId, workflow_type: WorkflowType.Static, emails: [] }, + ], + submissionPublicKey: 'test public key', + encryptedSubmissionSecretKey: 'test secret key', + encryptedContent: 'test encrypted content', + version: 1, + workflowStep: 0, + submittedSteps: [], + }) + + await submission.save() + + // Act + const result = await MultirespondentSubmission.findSingleMetadata( + String(formId), + String(submission._id), + ) + + // Assert + expect(result).toBeDefined() + expect(result?.mrf).toEqual( + buildMrfMetadata({ + workflow: submission.workflow, + workflowStep: submission.workflowStep, + submittedSteps: submission.submittedSteps, + }), + ) + expect(result?.refNo).toBeDefined() + expect(result?.number).toEqual(1) + }) + }) + + describe('findAllMetadataByFormId', () => { + it('should not return mrf metadata for storage mode form', async () => { + // Arrange + const formId = new ObjectId() + await EncryptedSubmission.create({ + submissionType: SubmissionType.Encrypt, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 1, + authType: FormAuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + // Act + const result = await EncryptedSubmission.findAllMetadataByFormId( + String(formId), + ) + + // Assert + expect(result.metadata[0].mrf).toBeUndefined() + expect(result.metadata[0].refNo).toBeDefined() + expect(result.metadata[0].number).toEqual(1) + expect(result.count).toEqual(1) + }) + + it('should return mrf metadata for an mrf form', async () => { + // Arrange + const formId = new ObjectId() + const workflowId = new ObjectId() + const fieldId = new ObjectId() + const submission = await MultirespondentSubmission.create({ + form: formId, + submissionType: SubmissionType.Multirespondent, + form_fields: [{ _id: fieldId, fieldType: BasicField.ShortText }], + form_logics: [], + workflow: [ + { _id: workflowId, workflow_type: WorkflowType.Static, emails: [] }, + ], + submissionPublicKey: 'test public key', + encryptedSubmissionSecretKey: 'test secret key', + encryptedContent: 'test encrypted content', + version: 1, + workflowStep: 0, + submittedSteps: [], + }) + + await submission.save() + + // Act + const result = await MultirespondentSubmission.findAllMetadataByFormId( + String(formId), + ) + + // Assert + expect(result.count).toEqual(1) + expect(result.metadata[0].mrf).toEqual( + buildMrfMetadata({ + workflowStep: submission.workflowStep, + workflow: submission.workflow, + submittedSteps: submission.submittedSteps, + }), + ) + expect(result.metadata[0].refNo).toBeDefined() + expect(result.metadata[0].number).toEqual(1) + }) + }) }) describe('Methods', () => { diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index b7eb22459d..6f110c6fb9 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -35,6 +35,7 @@ import { WebhookView, } from '../../types' import { getPaymentWebhookEventObject } from '../modules/payments/payment.service.utils' +import { buildMrfMetadata } from '../modules/submission/submission.utils' import { createQueryWithDateParam } from '../utils/date' import { FORM_SCHEMA_ID } from './form.server.model' @@ -366,7 +367,11 @@ EncryptSubmissionSchema.statics.findSingleMetadata = function ( const paymentMeta = result.payments?.[0] // Build submissionMetadata object. - const metadata = buildSubmissionMetadata(result, 1, paymentMeta) + const metadata = buildSubmissionMetadata({ + result, + currentNumber: 1, + paymentMeta, + }) return metadata }) @@ -435,11 +440,11 @@ EncryptSubmissionSchema.statics.findAllMetadataByFormId = function ( const metadata = results.map((result) => { const paymentMeta = result.payments?.[0] - const metadataEntry = buildSubmissionMetadata( + const metadataEntry = buildSubmissionMetadata({ result, currentNumber, paymentMeta, - ) + }) currentNumber-- return metadataEntry @@ -549,22 +554,35 @@ export const MultirespondentSubmissionSchema = new Schema< mrfVersion: { type: Number, }, + submittedSteps: { + type: Array, + default: [], + }, }) +type MultiRespondentAggregates = Pick< + IMultirespondentSubmissionSchema, + 'workflowStep' | 'workflow' | 'submittedSteps' +> +type MultiRespondentAggregateResult = MetadataAggregateResult & + MultiRespondentAggregates + MultirespondentSubmissionSchema.statics.findSingleMetadata = function ( formId: string, submissionId: string, ): Promise { - const pageResults: Promise = this.aggregate([ - { - $match: { - submissionType: SubmissionType.Multirespondent, - form: new mongoose.Types.ObjectId(formId), - _id: new mongoose.Types.ObjectId(submissionId), + const pageResults: Promise = this.aggregate( + [ + { + $match: { + submissionType: SubmissionType.Multirespondent, + form: new mongoose.Types.ObjectId(formId), + _id: new mongoose.Types.ObjectId(submissionId), + }, }, - }, - { $limit: 1 }, - ]).exec() + { $limit: 1 }, + ], + ).exec() return Promise.resolve(pageResults).then((results) => { if (!results || results.length <= 0) { @@ -572,9 +590,17 @@ MultirespondentSubmissionSchema.statics.findSingleMetadata = function ( } const result = results[0] - + const mrfMeta = { + workflowStep: result.workflowStep, + workflow: result.workflow, + submittedSteps: result.submittedSteps, + } // Build submissionMetadata object. - const metadata = buildSubmissionMetadata(result, 1) + const metadata = buildSubmissionMetadata({ + result, + currentNumber: 1, + mrfMeta, + }) return metadata }) @@ -595,18 +621,23 @@ MultirespondentSubmissionSchema.statics.findAllMetadataByFormId = function ( }> { const numToSkip = (page - 1) * pageSize // return documents within the page - const pageResults: Promise = this.aggregate([ - { $match: { form: new mongoose.Types.ObjectId(formId) } }, - { $sort: { created: -1 } }, - { $skip: numToSkip }, - { $limit: pageSize }, - { - $project: { - _id: 1, - created: 1, + const pageResults: Promise = this.aggregate( + [ + { $match: { form: new mongoose.Types.ObjectId(formId) } }, + { $sort: { created: -1 } }, + { $skip: numToSkip }, + { $limit: pageSize }, + { + $project: { + _id: 1, + created: 1, + workflowStep: 1, + workflow: 1, + submittedSteps: 1, + }, }, - }, - ]).exec() + ], + ).exec() const count = this.countDocuments({ @@ -619,11 +650,17 @@ MultirespondentSubmissionSchema.statics.findAllMetadataByFormId = function ( const metadata = results.map((result) => { const paymentMeta = result.payments?.[0] - const metadataEntry = buildSubmissionMetadata( + const mrfMeta = { + workflowStep: result.workflowStep, + workflow: result.workflow, + submittedSteps: result.submittedSteps, + } + const metadataEntry = buildSubmissionMetadata({ result, currentNumber, paymentMeta, - ) + mrfMeta, + }) currentNumber-- return metadataEntry @@ -657,6 +694,8 @@ MultirespondentSubmissionSchema.statics.getSubmissionCursorByFormId = function ( form_fields: 1, form_logics: 1, workflow: 1, + workflowStep: 1, + submittedSteps: 1, encryptedSubmissionSecretKey: 1, encryptedContent: 1, attachmentMetadata: 1, @@ -698,6 +737,7 @@ MultirespondentSubmissionSchema.statics.findEncryptedSubmissionById = function ( version: 1, workflowStep: 1, mrfVersion: 1, + submittedSteps: 1, }) .exec() } @@ -755,11 +795,17 @@ export const getMultirespondentSubmissionModel = ( >(SubmissionType.Multirespondent) } -const buildSubmissionMetadata = ( - result: MetadataAggregateResult, - currentNumber: number, - paymentMeta?: PaymentAggregates, -): SubmissionMetadata => { +const buildSubmissionMetadata = ({ + result, + currentNumber, + paymentMeta, + mrfMeta, +}: { + result: MetadataAggregateResult + currentNumber: number + paymentMeta?: PaymentAggregates + mrfMeta?: MultiRespondentAggregates +}): SubmissionMetadata => { return { number: currentNumber, refNo: result._id, @@ -779,6 +825,13 @@ const buildSubmissionMetadata = ( email: paymentMeta.email, } : null, + mrf: mrfMeta + ? buildMrfMetadata({ + workflow: mrfMeta.workflow, + workflowStep: mrfMeta.workflowStep, + submittedSteps: mrfMeta.submittedSteps, + }) + : undefined, } } diff --git a/src/app/modules/submission/__tests__/submission.service.spec.ts b/src/app/modules/submission/__tests__/submission.service.spec.ts index 6436e1a4dc..673754027b 100644 --- a/src/app/modules/submission/__tests__/submission.service.spec.ts +++ b/src/app/modules/submission/__tests__/submission.service.spec.ts @@ -41,9 +41,12 @@ import { AutoReplyOptions, BasicField, FormResponseMode, + FormWorkflowStepDto, SubmissionId, SubmissionMetadata, SubmissionType, + SubmittedNonApprovalStep, + WorkflowType, } from '../../../../../shared/types' import { PaymentNotFoundError } from '../../payments/payments.errors' import * as PaymentsService from '../../payments/payments.service' @@ -66,7 +69,10 @@ import { transformAttachmentMetasToSignedUrls, triggerVirusScanning, } from '../submission.service' -import { extractEmailConfirmationData } from '../submission.utils' +import { + buildMrfMetadata, + extractEmailConfirmationData, +} from '../submission.utils' jest.mock('src/app/services/mail/mail.service') const MockMailService = jest.mocked(MailService) @@ -1637,6 +1643,88 @@ describe('submission.service', () => { }) }) + describe('addMrfMetadata', () => { + it('should return original object without mrf metadata when submission is not multirespondent submission type', () => { + // Arrange + const mockInput = new PassThrough() + const actualTransformedData: any[] = [] + + // Act + // Build pipeline for testing + mockInput.pipe(SubmissionService.addMrfMetadata()).on('data', (data) => { + actualTransformedData.push(data) + }) + + // Emit events + const mockData = { + submissionType: SubmissionType.Encrypt, + formId: 'mockFormId', + submissionId: 'mockSubmissionId', + } + mockInput.emit('data', mockData) + mockInput.end() + + // Assert + expect(actualTransformedData).toEqual([mockData]) + }) + + it('should add mrf metadata when submission is multirespondent type', () => { + // Arrange + const WORKFLOW_STEP_1: FormWorkflowStepDto = { + _id: 'step_1_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [], + } + const WORKFLOW_STEP_2: FormWorkflowStepDto = { + _id: 'step_2_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [], + } + + const mockInput = new PassThrough() + const actualTransformedData: any[] = [] + + // Act + // Build pipeline for testing + mockInput.pipe(SubmissionService.addMrfMetadata()).on('data', (data) => { + actualTransformedData.push(data) + }) + + // Emit events + const mockData = { + submissionType: SubmissionType.Multirespondent, + formId: 'mockFormId', + submissionId: 'mockSubmissionId', + workflow: [WORKFLOW_STEP_1, WORKFLOW_STEP_2], + workflowStep: 0, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + } as SubmittedNonApprovalStep, + ], + } + mockInput.emit('data', mockData) + mockInput.end() + + // Assert + expect(actualTransformedData).toEqual([ + { + submissionType: SubmissionType.Multirespondent, + formId: 'mockFormId', + submissionId: 'mockSubmissionId', + mrfMeta: buildMrfMetadata({ + workflow: mockData.workflow, + workflowStep: mockData.workflowStep, + submittedSteps: mockData.submittedSteps, + }), + }, + ]) + }) + }) + describe('getSubmissionPaymentDto', () => { const MOCK_PAYMENT_ID = 'mockPaymentId' diff --git a/src/app/modules/submission/__tests__/submission.utils.spec.ts b/src/app/modules/submission/__tests__/submission.utils.spec.ts index a594bfa240..0cdcc90c99 100644 --- a/src/app/modules/submission/__tests__/submission.utils.spec.ts +++ b/src/app/modules/submission/__tests__/submission.utils.spec.ts @@ -2,10 +2,17 @@ import { ObjectId } from 'bson' import { readFileSync } from 'fs' import { cloneDeep, merge } from 'lodash' -import { BasicField, FormResponseMode } from '../../../../../shared/types' +import { + BasicField, + FormResponseMode, + FormWorkflowStepDto, + WorkflowStatus, + WorkflowType, +} from '../../../../../shared/types' import { SingleAnswerFieldResponse } from '../../../../types' import { areAttachmentsMoreThanLimit, + buildMrfMetadata, getInvalidFileExtensions, getResponseModeFilter, mapAttachmentsFromResponses, @@ -66,6 +73,155 @@ const getResponse = (_id: string, answer: string): SingleAnswerFieldResponse => }) as unknown as SingleAnswerFieldResponse describe('submission.utils', () => { + describe('buildMrfMetadata', () => { + const YES_NO_FIELD = { + _id: 'yes_no_field_id', + title: 'Yes or No', + description: '', + required: true, + disabled: false, + fieldType: BasicField.YesNo, + } + const WORKFLOW_STEP_1: FormWorkflowStepDto = { + _id: 'step_1_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [], + } + + const WORKFLOW_STEP_2: FormWorkflowStepDto = { + _id: 'step_2_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [YES_NO_FIELD._id], + } + const WORKFLOW_APPROVAL_STEP: FormWorkflowStepDto = { + _id: 'approval_step_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [YES_NO_FIELD._id], + approval_field: YES_NO_FIELD._id, + } + + it('should build mrf metadata successfully for pending submission without approval step', () => { + const metadata = buildMrfMetadata({ + workflow: [WORKFLOW_STEP_1, WORKFLOW_STEP_2], + workflowStep: 0, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }) + + expect(metadata).toEqual({ + workflowCurrentStepNumber: 1, + workflowNumTotalSteps: 2, + workflowStatus: WorkflowStatus.PENDING, + }) + }) + + it('should build mrf metadata successfully for completed submission without approval step', () => { + const metadata = buildMrfMetadata({ + workflow: [WORKFLOW_STEP_1, WORKFLOW_STEP_2], + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: false, + submittedAt: '2024-01-02T00:00:00.000Z', + }, + ], + }) + + expect(metadata).toEqual({ + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 2, + workflowStatus: WorkflowStatus.COMPLETED, + }) + }) + + it('should build mrf metadata successfully for pending submission with approval step', () => { + const metadata = buildMrfMetadata({ + workflow: [WORKFLOW_STEP_1, WORKFLOW_APPROVAL_STEP, WORKFLOW_STEP_2], + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: true, + status: WorkflowStatus.APPROVED, + submittedAt: '2024-01-02T00:00:00.000Z', + }, + ], + }) + + expect(metadata).toEqual({ + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 3, + workflowStatus: WorkflowStatus.PENDING, + }) + }) + + it('should build mrf metadata successfully for approval submission with approval step', () => { + const metadata = buildMrfMetadata({ + workflow: [WORKFLOW_STEP_1, WORKFLOW_APPROVAL_STEP, WORKFLOW_STEP_2], + workflowStep: 2, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: true, + status: WorkflowStatus.APPROVED, + submittedAt: '2024-01-02T00:00:00.000Z', + }, + { + isApproval: false, + submittedAt: '2024-01-03T00:00:00.000Z', + }, + ], + }) + + expect(metadata).toEqual({ + workflowCurrentStepNumber: 3, + workflowNumTotalSteps: 3, + workflowStatus: WorkflowStatus.APPROVED, + }) + }) + + it('should build mrf metadata successfully for rejected submission with approval step', () => { + const metadata = buildMrfMetadata({ + workflow: [WORKFLOW_STEP_1, WORKFLOW_APPROVAL_STEP, WORKFLOW_STEP_2], + workflowStep: 1, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + { + isApproval: true, + status: WorkflowStatus.REJECTED, + submittedAt: '2024-01-02T00:00:00.000Z', + }, + ], + }) + + expect(metadata).toEqual({ + workflowCurrentStepNumber: 2, + workflowNumTotalSteps: 3, + workflowStatus: WorkflowStatus.REJECTED, + }) + }) + }) + describe('getResponseModeFilter', () => { const ALL_FIELD_TYPES = Object.values(BasicField).map((fieldType) => ({ fieldType, diff --git a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts index 33c40537a6..198a5da365 100644 --- a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts +++ b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts @@ -331,6 +331,7 @@ describe('multiresponodent-submision.controller', () => { expect( MockMultiRespondentSubmissionService.updateMultiRespondentFormSubmission, ).toHaveBeenCalledOnce() + expect( omit( MockMultiRespondentSubmissionService @@ -338,7 +339,6 @@ describe('multiresponodent-submision.controller', () => { 'logMeta', ), ).toEqual({ - formId: mockFormId, submissionId: mockSubmissionId, encryptedPayload: mockSubmitMrfReq.formsg.encryptedPayload, form: mockSubmitMrfReq.formsg.formDef, diff --git a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts index 5b22c65008..01fc7c6c76 100644 --- a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts +++ b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts @@ -10,12 +10,14 @@ import { ChildBirthRecordsResponseV3, EmailResponseV3, FieldResponsesV3, + FormFieldDto, FormWorkflowStepDto, LongTextResponseV3, NumberResponseV3, ShortTextResponseV3, SubmissionType, TableResponseV3, + WorkflowStatus, WorkflowType, } from 'shared/types' @@ -41,18 +43,38 @@ import { } from '../multirespondent-submission.utils' describe('multirespondent-submission.utils', () => { + const WORKFLOW_STEP_1: FormWorkflowStepDto = { + _id: 'step_1_id', + workflow_type: WorkflowType.Static, + emails: ['example@example.com'], + edit: [], + } + describe('createMultirespondentSubmissionDto', () => { it('should create an encrypted submission DTO sucessfully', () => { // Arrange const createdDate = new Date() const submissionData = { + submissionType: SubmissionType.Multirespondent, _id: new ObjectId(), created: createdDate, submissionPublicKey: 'some public key', encryptedSubmissionSecretKey: 'some encrypted secret key', encryptedContent: 'some encrypted content', - submissionType: SubmissionType.Multirespondent, - } as MultirespondentSubmissionData + workflow: [WORKFLOW_STEP_1], + workflowStep: 0, + form_fields: [], + form_logics: [], + attachmentMetadata: {}, + version: 3, + mrfVersion: 3, + submittedSteps: [ + { + isApproval: false, + submittedAt: '2024-01-01T00:00:00.000Z', + }, + ], + } as unknown as MultirespondentSubmissionData const attachmentPresignedUrls = { someSubmissionId: 'some presigned url', } @@ -75,6 +97,17 @@ describe('multirespondent-submission.utils', () => { submissionData.encryptedSubmissionSecretKey, attachmentMetadata: attachmentPresignedUrls, submissionType: SubmissionType.Multirespondent, + workflow: submissionData.workflow, + form_fields: submissionData.form_fields, + form_logics: submissionData.form_logics, + version: submissionData.version, + workflowStep: submissionData.workflowStep, + mrfVersion: submissionData.mrfVersion, + mrfMeta: { + workflowCurrentStepNumber: 1, + workflowNumTotalSteps: 1, + workflowStatus: WorkflowStatus.COMPLETED, + }, }) }) }) @@ -108,7 +141,7 @@ describe('multirespondent-submission.utils', () => { const result = validateMrfFieldResponses({ formId: mockFormId, visibleFieldIds: mockVisibleFieldIds, - formFields: mockFormFields, + formFields: mockFormFields as FormFieldDto[], responses: mockResponses, }) @@ -142,7 +175,7 @@ describe('multirespondent-submission.utils', () => { validateMrfFieldResponses({ formId: mockFormId, visibleFieldIds: mockVisibleFieldIds, - formFields: mockFormFields, + formFields: mockFormFields as FormFieldDto[], responses: mockResponses, }) @@ -185,7 +218,7 @@ describe('multirespondent-submission.utils', () => { validateMrfFieldResponses({ formId: mockFormId, visibleFieldIds: mockVisibleFieldIds, - formFields: mockFormFields, + formFields: mockFormFields as FormFieldDto[], responses: mockResponses, }) 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 b237bd4672..b509db3356 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -183,7 +183,6 @@ const updateMultirespondentSubmission = async ( const updateMultiRespondentFormSubmissionResult = await updateMultiRespondentFormSubmission({ - formId, submissionId, form, encryptedPayload, 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 81e3977fca..0b083aac2b 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts @@ -7,6 +7,9 @@ import { FieldResponsesV3, FormResponseMode, FormWorkflowStepDto, + SubmittedApprovalStep, + SubmittedNonApprovalStep, + WorkflowStatus, } from '../../../../../shared/types' import { getMultirespondentSubmissionEditPath } from '../../../../../shared/utils/urls' import { @@ -350,6 +353,11 @@ export const createMultiRespondentFormSubmission = ({ mrfVersion, } = encryptedPayload + const submittedStepMeta: SubmittedNonApprovalStep = { + isApproval: false, // first step cannot be approval step + submittedAt: new Date().toISOString(), + } + const submissionContent: MultirespondentSubmissionContent = { form: form._id, authType: form.authType, @@ -364,6 +372,7 @@ export const createMultiRespondentFormSubmission = ({ version, workflowStep: 0, mrfVersion, + submittedSteps: [submittedStepMeta], } const submission = new MultirespondentSubmission(submissionContent) @@ -469,7 +478,6 @@ export const updateMultiRespondentFormSubmission = ({ encryptedPayload, logMeta, }: { - formId: string submissionId: string form: IPopulatedMultirespondentForm encryptedPayload: MultirespondentSubmissionDto @@ -512,6 +520,41 @@ export const updateMultiRespondentFormSubmission = ({ mrfVersion, } = encryptedPayload + const isApprovalForm = checkIsFormApproval(form) + const isStepRejectedResult = checkIsStepRejected({ + zeroIndexedStepNumber: workflowStep, + form, + responses: encryptedPayload.responses, + }) + if (isStepRejectedResult.isErr()) { + logger.error({ + message: 'Error occurred when checking if step is rejected', + meta: logMeta, + error: isStepRejectedResult.error, + }) + return errAsync(isStepRejectedResult.error) + } + + const isStepRejected = isStepRejectedResult.value + const submittedStepMeta = isApprovalForm + ? ({ + status: isStepRejected + ? WorkflowStatus.REJECTED + : WorkflowStatus.APPROVED, + stepNumber: workflowStep, + isApproval: true, + submittedAt: new Date().toISOString(), + } as SubmittedApprovalStep) + : ({ + isApproval: false, + stepNumber: workflowStep, + submittedAt: new Date().toISOString(), + } as SubmittedNonApprovalStep) + + submission.submittedSteps = [ + ...(submission.submittedSteps ?? []), + submittedStepMeta, + ] submission.responseMetadata = responseMetadata submission.submissionPublicKey = submissionPublicKey submission.encryptedSubmissionSecretKey = encryptedSubmissionSecretKey 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 6ba339a9ee..c27dca02ab 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts @@ -5,6 +5,7 @@ import { MyInfoAttribute, SubmissionErrorDto, SubmissionResponseDto, + SubmittedStep, } from '../../../../../shared/types' import { MultirespondentFormCompleteDto, @@ -88,6 +89,7 @@ export type MultirespondentSubmissionContent = { version: number workflowStep: number mrfVersion: number + submittedSteps: SubmittedStep[] } export type StrippedAttachmentResponseV3 = AttachmentResponseV3 & { diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts index b405842203..b72b172594 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts @@ -25,6 +25,7 @@ import { ProcessingError, ValidateFieldErrorV3, } from '../submission.errors' +import { buildMrfMetadata } from '../submission.utils' /** * Creates and returns a MultirespondentSubmissionDto object from submissionData and @@ -52,6 +53,11 @@ export const createMultirespondentSubmissionDto = ( version: submissionData.version, workflowStep: submissionData.workflowStep, mrfVersion: submissionData.mrfVersion, + mrfMeta: buildMrfMetadata({ + workflow: submissionData.workflow, + workflowStep: submissionData.workflowStep, + submittedSteps: submissionData.submittedSteps, + }), } } diff --git a/src/app/modules/submission/submission.controller.ts b/src/app/modules/submission/submission.controller.ts index 9575926099..8f21268edf 100644 --- a/src/app/modules/submission/submission.controller.ts +++ b/src/app/modules/submission/submission.controller.ts @@ -29,6 +29,7 @@ import { createStorageModeSubmissionDto } from './encrypt-submission/encrypt-sub import { createMultirespondentSubmissionDto } from './multirespondent-submission/multirespondent-submission.utils' import { InvalidSubmissionTypeError } from './submission.errors' import { + addMrfMetadata, addPaymentDataStream, getEncryptedSubmissionData, getQuarantinePresignedPostData, @@ -94,7 +95,7 @@ export const getMetadata: ControllerHandler< level: PermissionLevel.Read, }), ) - // Step 3: Check whether form is encrypt mode. + // Step 3: Check whether form is encrypt or multirespondent mode. .andThen(checkFormIsEncryptModeOrMultirespondent) // Step 4: Retrieve submission metadata. .andThen((form) => { @@ -328,7 +329,7 @@ export const streamEncryptedResponses: ControllerHandler< level: PermissionLevel.Read, }), ) - // Step 3: Check whether form is encrypt mode. + // Step 3: Check whether form is encrypt or multirespondent mode. .andThen(checkFormIsEncryptModeOrMultirespondent) // Step 4: Retrieve submissions cursor. .andThen((form) => @@ -370,6 +371,7 @@ export const streamEncryptedResponses: ControllerHandler< urlValidDuration: (req.session?.cookie.maxAge ?? 0) / 1000, }), ) + .pipe(addMrfMetadata()) // TODO: Can we include this within the cursor query as aggregation pipeline // instead, so that we make one query to mongo rather than two. .pipe(addPaymentDataStream()) diff --git a/src/app/modules/submission/submission.service.ts b/src/app/modules/submission/submission.service.ts index 3f4a7e722f..e29e34ad96 100644 --- a/src/app/modules/submission/submission.service.ts +++ b/src/app/modules/submission/submission.service.ts @@ -18,6 +18,7 @@ import { SubmissionMetadata, SubmissionMetadataList, SubmissionPaymentDto, + SubmissionType, } from '../../../../shared/types' import { EmailRespondentConfirmationField, @@ -26,6 +27,7 @@ import { IMultirespondentSubmissionModel, IPopulatedForm, ISubmissionSchema, + MultirespondentSubmissionCursorData, StorageModeSubmissionCursorData, SubmissionData, } from '../../../types' @@ -86,6 +88,7 @@ import { } from './submission.types' import { areAttachmentsMoreThanLimit, + buildMrfMetadata, fileSizeLimitBytes, getEncryptedSubmissionModelByResponseMode, getInvalidFileExtensions, @@ -800,6 +803,36 @@ export const getSubmissionCursor = ( ) } +/** + * Adds mrf metadata to each submission. + */ +export const addMrfMetadata = (): Transform => { + return new Transform({ + objectMode: true, + transform: async ( + data: + | StorageModeSubmissionCursorData + | MultirespondentSubmissionCursorData, + _encoding, + callback, + ) => { + if (data.submissionType === SubmissionType.Multirespondent) { + const { workflow, workflowStep, submittedSteps, ...rest } = data + const dataWithMrfMeta = { + ...rest, + mrfMeta: buildMrfMetadata({ + workflow, + workflowStep, + submittedSteps, + }), + } + return callback(null, dataWithMrfMeta) + } + return callback(null, data) + }, + }) +} + /** * Returns a Transform pipeline that transforms all attachment metadata of each * data chunk from the object path to the S3 signed URL so it can be retrieved diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 495fc5f91d..e6af3bb54f 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -25,6 +25,10 @@ import { MyInfoAttribute, SubmissionAttachment, SubmissionAttachmentsMap, + SubmissionMrfMetadata, + SubmittedApprovalStep, + SubmittedStep, + WorkflowStatus, } from '../../../../shared/types' import * as FileValidation from '../../../../shared/utils/file-validation' import { @@ -34,6 +38,7 @@ import { IEncryptSubmissionModel, IFormDocument, IMultirespondentSubmissionModel, + IMultirespondentSubmissionSchema, IPopulatedEncryptedForm, IPopulatedForm, IPopulatedMultirespondentForm, @@ -147,10 +152,6 @@ import { const logger = createLoggerWithLabel(module) -const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) -const MultirespondentSubmissionModel = - getMultirespondentSubmissionModel(mongoose) - type ResponseModeFilterParam = { fieldType: BasicField } @@ -402,6 +403,10 @@ export const getEncryptedSubmissionModelByResponseMode = ( IEncryptSubmissionModel | IMultirespondentSubmissionModel, ResponseModeError > => { + const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) + const MultirespondentSubmissionModel = + getMultirespondentSubmissionModel(mongoose) + switch (responseMode) { case FormResponseMode.Encrypt: return ok(EncryptSubmissionModel) @@ -817,3 +822,65 @@ export const getCookieNameByAuthType = ( return JwtName[authType] } } + +/** + * Determines the workflow status of a submission based on the submitted steps and the total number of steps. + * @param submittedSteps - The submitted steps of the submission. + * @param numTotalSteps - The total number of steps in the workflow. + * @returns The workflow status of the submission based on the enum `WorkflowStatus`. + * Otherwise, returns `undefined` if no submitted steps or total steps are found or when no workflow has been defined by the form admin. + */ +const getMrfSubmissionWorkflowStatus = ( + submittedSteps: SubmittedStep[], + numTotalSteps: number, +): WorkflowStatus | undefined => { + if (submittedSteps.length <= 0 || numTotalSteps <= 0) { + // NOTE: this occurs when no steps are recorded for submissions prior to this change or when no workflow is defined. + return undefined + } + const latestSubmittedStep = submittedSteps[submittedSteps.length - 1] + if ( + latestSubmittedStep.isApproval && + latestSubmittedStep.status === WorkflowStatus.REJECTED + ) { + return WorkflowStatus.REJECTED + } + if (submittedSteps.length === numTotalSteps) { + const latestApprovalStep = submittedSteps + .slice() + .reverse() + .find((step) => step.isApproval) as SubmittedApprovalStep | undefined + if ( + latestApprovalStep && + latestApprovalStep.status === WorkflowStatus.APPROVED + ) { + return WorkflowStatus.APPROVED + } + return WorkflowStatus.COMPLETED + } + return WorkflowStatus.PENDING +} + +/** + * Builds the metadata for a multirespondent form submission. + */ +export const buildMrfMetadata = ({ + workflow, + workflowStep, + submittedSteps, +}: Pick< + IMultirespondentSubmissionSchema, + 'workflow' | 'workflowStep' | 'submittedSteps' +>): SubmissionMrfMetadata => { + const workflowCurrentStepNumber = workflowStep + 1 // since workflowStep is zero indexed. + const workflowNumTotalSteps = workflow.length + const workflowStatus = getMrfSubmissionWorkflowStatus( + submittedSteps ?? [], + workflow.length, + ) + return { + workflowCurrentStepNumber, + workflowNumTotalSteps, + workflowStatus, + } +} diff --git a/src/types/submission.ts b/src/types/submission.ts index be81be6f82..cc6e13295b 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -149,6 +149,9 @@ export type MultirespondentSubmissionCursorData = Pick< | 'id' | 'version' | 'mrfVersion' + | 'workflowStep' + | 'submittedSteps' + | 'workflow' > & { attachmentMetadata?: Record } & Document export type SubmissionCursorData = @@ -183,6 +186,7 @@ export type MultirespondentSubmissionData = { | 'version' | 'workflowStep' | 'mrfVersion' + | 'submittedSteps' > & Document