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