From fc7dd515582d189b425df8b03abad06b56eded2f Mon Sep 17 00:00:00 2001 From: Ken Lee Shu Ming Date: Tue, 2 Jan 2024 16:58:27 +0800 Subject: [PATCH 1/5] fix: formendpage missing context provider (#6989) --- .../FormEndPage/FormEndPage.stories.tsx | 19 ++++++++++++++++++- .../FormLogo/PublicFormLogo.stories.tsx | 5 ++++- .../FormStartPage/FormStartPage.stories.tsx | 5 ++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/public-form/components/FormEndPage/FormEndPage.stories.tsx b/frontend/src/features/public-form/components/FormEndPage/FormEndPage.stories.tsx index 69493bea83..e33db29525 100644 --- a/frontend/src/features/public-form/components/FormEndPage/FormEndPage.stories.tsx +++ b/frontend/src/features/public-form/components/FormEndPage/FormEndPage.stories.tsx @@ -1,20 +1,37 @@ +import { MemoryRouter } from 'react-router-dom' import { Meta, Story } from '@storybook/react' import { FormColorTheme } from '~shared/types' +import { getPublicFormResponse } from '~/mocks/msw/handlers/public-form' + import { getMobileViewParameters } from '~utils/storybook' +import { PublicFormProvider } from '~features/public-form/PublicFormProvider' + import { FormEndPage, FormEndPageProps } from './FormEndPage' export default { title: 'Pages/PublicFormPage/FormEndPage', component: FormEndPage, - decorators: [], + decorators: [ + (storyFn) => ( + + + {storyFn()} + + + ), + ], parameters: { backgrounds: { default: 'light', }, layout: 'fullscreen', + msw: [getPublicFormResponse()], }, } as Meta diff --git a/frontend/src/features/public-form/components/FormLogo/PublicFormLogo.stories.tsx b/frontend/src/features/public-form/components/FormLogo/PublicFormLogo.stories.tsx index e96c748f72..d0d629fd9b 100644 --- a/frontend/src/features/public-form/components/FormLogo/PublicFormLogo.stories.tsx +++ b/frontend/src/features/public-form/components/FormLogo/PublicFormLogo.stories.tsx @@ -21,7 +21,10 @@ export default { decorators: [ (storyFn) => ( - + {storyFn()} diff --git a/frontend/src/features/public-form/components/FormStartPage/FormStartPage.stories.tsx b/frontend/src/features/public-form/components/FormStartPage/FormStartPage.stories.tsx index 5aaf0eaf32..04cb19be94 100644 --- a/frontend/src/features/public-form/components/FormStartPage/FormStartPage.stories.tsx +++ b/frontend/src/features/public-form/components/FormStartPage/FormStartPage.stories.tsx @@ -29,7 +29,10 @@ export default { decorators: [ (storyFn) => ( - + {storyFn()} From 8a969441b802f3ed54e9f97e4ed3cf148dbe8e3a Mon Sep 17 00:00:00 2001 From: sebastianwzq <136435307+sebastianwzq@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:55:24 +0800 Subject: [PATCH 2/5] fix(deps): [Snyk] Security upgrade axios from 1.6.2 to 1.6.3 (#6983) * fix: frontend/package.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-AXIOS-6124857 * fix(deps): upgrade axios and twilio in all packages --------- Co-authored-by: snyk-bot Co-authored-by: Justyn Oh --- frontend/package-lock.json | 14 ++--- frontend/package.json | 2 +- package-lock.json | 66 ++++++---------------- package.json | 4 +- serverless/virus-scanner/package-lock.json | 14 ++--- serverless/virus-scanner/package.json | 2 +- 6 files changed, 34 insertions(+), 68 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9347537478..fcea3681b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", "@types/stopword": "^2.0.1", - "axios": "^1.6.2", + "axios": "^1.6.3", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", "comlink": "^4.3.1", @@ -17319,9 +17319,9 @@ } }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -61379,9 +61379,9 @@ "dev": true }, "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index 4cf9118472..f1f18d1754 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", "@types/stopword": "^2.0.1", - "axios": "^1.6.2", + "axios": "^1.6.3", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", "comlink": "^4.3.1", diff --git a/package-lock.json b/package-lock.json index 97c20d714a..74cecaef39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1354.0", - "axios": "^1.6.2", + "axios": "^1.6.3", "bcrypt": "^5.1.0", "bluebird": "^3.5.2", "body-parser": "^1.20.1", @@ -102,7 +102,7 @@ "triple-beam": "^1.3.0", "ts-essentials": "^9.3.1", "tweetnacl": "^1.0.1", - "twilio": "~4.18.0", + "twilio": "~4.19.3", "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "ulid": "^2.3.0", @@ -9751,9 +9751,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -29529,11 +29529,11 @@ "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, "node_modules/twilio": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.18.0.tgz", - "integrity": "sha512-f8etm0l0G2zexwM6wdpjUjLe1iPLLsr0sWTMkkkLUGQ2GAaQcCclXQa6t4gNCDcvgH5wa3vOuTL9p0Ny9cdChQ==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.19.3.tgz", + "integrity": "sha512-3X5Czl9Vg4QFl+2pnfMQ+H8YfEDQ4WeuAmqjUpbK65x0DfmxTCHuPEFWUKVZCJZew6iltJB/1whhVvIKETe54A==", "dependencies": { - "axios": "^0.26.1", + "axios": "^1.6.0", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.0", @@ -29546,31 +29546,6 @@ "node": ">=14.0" } }, - "node_modules/twilio/node_modules/axios": { - "version": "0.26.1", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.14.8" - } - }, - "node_modules/twilio/node_modules/follow-redirects": { - "version": "1.15.2", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/twilio/node_modules/qs": { "version": "6.11.0", "license": "BSD-3-Clause", @@ -38914,9 +38889,9 @@ "dev": true }, "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -52584,11 +52559,11 @@ "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" }, "twilio": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.18.0.tgz", - "integrity": "sha512-f8etm0l0G2zexwM6wdpjUjLe1iPLLsr0sWTMkkkLUGQ2GAaQcCclXQa6t4gNCDcvgH5wa3vOuTL9p0Ny9cdChQ==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.19.3.tgz", + "integrity": "sha512-3X5Czl9Vg4QFl+2pnfMQ+H8YfEDQ4WeuAmqjUpbK65x0DfmxTCHuPEFWUKVZCJZew6iltJB/1whhVvIKETe54A==", "requires": { - "axios": "^0.26.1", + "axios": "^1.6.0", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.0", @@ -52598,15 +52573,6 @@ "xmlbuilder": "^13.0.2" }, "dependencies": { - "axios": { - "version": "0.26.1", - "requires": { - "follow-redirects": "^1.14.8" - } - }, - "follow-redirects": { - "version": "1.15.2" - }, "qs": { "version": "6.11.0", "requires": { diff --git a/package.json b/package.json index fc49bb0187..b3f8fa84ca 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1354.0", - "axios": "^1.6.2", + "axios": "^1.6.3", "bcrypt": "^5.1.0", "bluebird": "^3.5.2", "body-parser": "^1.20.1", @@ -148,7 +148,7 @@ "triple-beam": "^1.3.0", "ts-essentials": "^9.3.1", "tweetnacl": "^1.0.1", - "twilio": "~4.18.0", + "twilio": "~4.19.3", "ui-select": "^0.19.8", "uid-generator": "^2.0.0", "ulid": "^2.3.0", diff --git a/serverless/virus-scanner/package-lock.json b/serverless/virus-scanner/package-lock.json index 08279ae749..8c5bd22be4 100644 --- a/serverless/virus-scanner/package-lock.json +++ b/serverless/virus-scanner/package-lock.json @@ -12,7 +12,7 @@ "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/client-ssm": "^3.363.0", "aws-lambda-ric": "^2.1.0", - "axios": "^1.6.2", + "axios": "^1.6.3", "clamscan": "^2.1.2", "convict": "^6.2.4", "http-status-codes": "^2.2.0", @@ -3789,9 +3789,9 @@ } }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -14694,9 +14694,9 @@ } }, "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", + "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/serverless/virus-scanner/package.json b/serverless/virus-scanner/package.json index 1b387997e7..59efa199db 100644 --- a/serverless/virus-scanner/package.json +++ b/serverless/virus-scanner/package.json @@ -16,7 +16,7 @@ "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/client-ssm": "^3.363.0", "aws-lambda-ric": "^2.1.0", - "axios": "^1.6.2", + "axios": "^1.6.3", "clamscan": "^2.1.2", "convict": "^6.2.4", "http-status-codes": "^2.2.0", From fc322d2a5ad507673c93696375a4aeed17b5d4c0 Mon Sep 17 00:00:00 2001 From: wanlingt <56983748+wanlingt@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:11:34 +0800 Subject: [PATCH 3/5] feat(mrf): add static workflow routing (#6968) * feat: multi-respondent form prototype with edit response links * chore: fix build errors * fix: guard against editing authtype * feat: MRF encryption scheme * test: fix test files * chore: fix build error * chore: update copy for form creation modal * chore: update variable name in frontend public form provider * feat: attachment handling in multi-respondent forms for respondent 1+ * chore: fix build errors * chore: move some stuff around * test: fix backend tests * test: update test suite * chore: copy changes * chore: more copy changes, add test mock clearing so that tests pass * fix: nits * chore: add linear ticket labels to all TODOs * feat: gate mrf form creation on frontend and backend * fix: require publicKey on form creation for multi-respondent forms * feat: add workflowStep to submission schema * chore(frm-1599): update multiparty create form modal icon * fix: typing issues * fix: remove unused import * fix: update joi validator to array * fix: column cell not respecting disable required validation * fix: mislabelled test name * feat: add workflow emails to form * feat: add workflow run to multirespondent form processing * feat: add error state for invalid emails in frontend workflow settings * feat: use featureflag to conditionally render workflow icon * fix: add placeholder text to input * fix: remove multiparty icon * fix: add multiparty icon * fix: run next workflow step instead of current workflow * fix: model ux issue when admin without mrf flag creates new form * feat(mrf): response link with key (#6967) * feat: append key in response link * refactor: extract iskeypairvalid to fe utils * feat: add prefilling of key * feat: add auto decryption if key is correct * refactor: extract key into getmultirespondentsubmissioneditpath utils * test: add workflowStep to form schema * ref: check for responsemode once * fix: use strokeWidth in svg * fix: reset error state * ref: remove unused component and simplify if condition * fix: remove console.log * fix: use react-hook-form for workflow details * fix: remove duplicate body version * fix: reorder workflow in settings page * fix: copy changes * fix: display previous workflow emails in settings * feat: always fetch response when MRF is enabled * fix: remove comment --------- Co-authored-by: Justyn Oh Co-authored-by: Ken --- frontend/src/assets/icons/MultiParty.tsx | 22 +-- .../IndividualResponsePage/queries.ts | 9 +- .../admin-form/settings/SettingsPage.tsx | 18 ++- .../admin-form/settings/SettingsService.ts | 15 ++ .../settings/SettingsWorkflowPage.tsx | 43 ++++++ .../WorkflowDetailsInput.tsx | 135 ++++++++++++++++++ .../WorkflowUnsupportedMsg.tsx | 15 ++ .../features/admin-form/settings/mutations.ts | 16 +++ shared/constants/form.ts | 1 + shared/types/form/form.ts | 13 ++ shared/types/submission.ts | 2 + ...respondent-submission.server.model.spec.ts | 8 ++ .../__tests__/submission.server.model.spec.ts | 1 + src/app/models/form.server.model.ts | 27 ++++ src/app/models/submission.server.model.ts | 5 + .../form/admin-form/admin-form.middlewares.ts | 13 ++ .../multirespondent-submission.controller.ts | 115 ++++++++++++++- .../multirespondent-submission.middleware.ts | 71 ++++++++- .../multirespondent-submission.types.ts | 2 +- .../multirespondent-submission.utils.ts | 1 + .../submission/receiver/receiver.types.ts | 1 + src/app/services/mail/mail.constants.ts | 1 + src/app/services/mail/mail.service.ts | 37 +++++ src/app/services/mail/mail.types.ts | 6 + src/app/services/mail/mail.utils.ts | 17 +++ .../templates/mrf-workflow-email.view.html | 112 +++++++++++++++ src/types/api/multirespondent_submission.ts | 3 + src/types/form.ts | 2 + src/types/submission.ts | 1 + 29 files changed, 690 insertions(+), 22 deletions(-) create mode 100644 frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx create mode 100644 frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx create mode 100644 frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx create mode 100644 src/app/views/templates/mrf-workflow-email.view.html diff --git a/frontend/src/assets/icons/MultiParty.tsx b/frontend/src/assets/icons/MultiParty.tsx index d586c792ad..ad5c075345 100644 --- a/frontend/src/assets/icons/MultiParty.tsx +++ b/frontend/src/assets/icons/MultiParty.tsx @@ -3,18 +3,20 @@ export const MultiParty = ( ): JSX.Element => { return ( - + + + ) } diff --git a/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts b/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts index 57b03393a8..47c59dbc7a 100644 --- a/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts +++ b/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts @@ -3,6 +3,8 @@ import { useParams } from 'react-router-dom' import { useToast } from '~hooks/useToast' +import { useUser } from '~features/user/queries' + import { getDecryptedSubmissionById } from '../AdminSubmissionsService' import { adminFormResponsesKeys } from '../queries' import { useStorageResponsesContext } from '../ResponsesPage/storage' @@ -16,6 +18,9 @@ export const useIndividualSubmission = () => { }) const { formId, submissionId } = useParams() + const { user } = useUser() + const displayWorkflow = user?.betaFlags?.mrf + if (!formId || !submissionId) { throw new Error('No formId or submissionId provided') } @@ -26,8 +31,8 @@ export const useIndividualSubmission = () => { adminFormResponsesKeys.individual(formId, submissionId), () => getDecryptedSubmissionById({ formId, submissionId, secretKey }), { - // Will never update once fetched. - staleTime: Infinity, + // For users with MRF enabled, will always fetch the response. Otherwise, response Will never update once fetched. + staleTime: displayWorkflow ? 0 : Infinity, enabled: !!secretKey, onError: (e) => { toast({ diff --git a/frontend/src/features/admin-form/settings/SettingsPage.tsx b/frontend/src/features/admin-form/settings/SettingsPage.tsx index a0b974075f..1ed27731c1 100644 --- a/frontend/src/features/admin-form/settings/SettingsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import { import { featureFlags } from '~shared/constants' +import { MultiParty } from '~assets/icons/MultiParty' import { ADMINFORM_RESULTS_SUBROUTE, ADMINFORM_ROUTE } from '~constants/routes' import { useDraggable } from '~hooks/useDraggable' @@ -27,6 +28,7 @@ import { SettingsGeneralPage } from './SettingsGeneralPage' import { SettingsPaymentsPage } from './SettingsPaymentsPage' import { SettingsTwilioPage } from './SettingsTwilioPage' import { SettingsWebhooksPage } from './SettingsWebhooksPage' +import { SettingsWorkflowPage } from './SettingsWorkflowPage' const settingsTabsOrder = ['general', 'singpass', 'twilio', 'webhooks'] @@ -52,6 +54,8 @@ export const SettingsPage = (): JSX.Element => { const displayPayments = user?.betaFlags?.payment || flags?.has(featureFlags.payment) + const displayWorkflow = user?.betaFlags?.mrf + const [tabIndex, setTabIndex] = useState( settingsTabsOrder.indexOf(settingsTab ?? ''), ) @@ -65,7 +69,11 @@ export const SettingsPage = (): JSX.Element => { settingsTabsOrder.push('payments') setTabIndex(settingsTabsOrder.indexOf(settingsTab ?? '')) } - }, [displayPayments, settingsTab]) + if (displayWorkflow) { + settingsTabsOrder.push('workflow') + setTabIndex(settingsTabsOrder.indexOf(settingsTab ?? '')) + } + }, [displayWorkflow, displayPayments, settingsTab]) const handleTabChange = (index: number) => { setTabIndex(index) @@ -120,6 +128,9 @@ export const SettingsPage = (): JSX.Element => { {displayPayments && ( )} + {displayWorkflow && ( + + )} { )} + {displayWorkflow && ( + + + + )} diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts index bb1691c9e8..0592b17614 100644 --- a/frontend/src/features/admin-form/settings/SettingsService.ts +++ b/frontend/src/features/admin-form/settings/SettingsService.ts @@ -3,6 +3,7 @@ import Stripe from 'stripe' import { EmailFormSettings, FormSettings, + MultirespondentFormSettings, SettingsUpdateDto, StorageFormSettings, } from '~shared/types/form/form' @@ -22,6 +23,12 @@ type UpdateStorageFormFn = ( settingsToUpdate: StorageFormSettings[T], ) => Promise +type UpdateMultirespondentFormFn = + ( + formId: string, + settingsToUpdate: MultirespondentFormSettings[T], + ) => Promise + type UpdateFormFn = ( formId: string, settingsToUpdate: FormSettings[T], @@ -136,6 +143,14 @@ export const updateGstEnabledFlag = async ( }) } +export const updateWorkflowSettings: UpdateMultirespondentFormFn< + 'workflow' +> = async ( + formId, + newWorkflowSettings: MultirespondentFormSettings['workflow'], +) => { + return updateFormSettings(formId, { workflow: newWorkflowSettings }) +} /** * Internal function that calls the PATCH API. * @param formId the id of the form to update diff --git a/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx b/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx new file mode 100644 index 0000000000..2b167225c3 --- /dev/null +++ b/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx @@ -0,0 +1,43 @@ +import { Link, Text, Wrap } from '@chakra-ui/react' + +import { FormResponseMode } from '~shared/types' + +import { GUIDE_TWILIO } from '~constants/links' + +import { CategoryHeader } from './components/CategoryHeader' +import { WorkflowDetailsInput } from './components/WorkflowSettingsSection/WorkflowDetailsInput' +import { WorkflowUnsupportedMsg } from './components/WorkflowSettingsSection/WorkflowUnsupportedMsg' +import { useAdminFormSettings } from './queries' + +export const SettingsWorkflowPage = (): JSX.Element => { + const { data: settings, isLoading } = useAdminFormSettings() + if (!settings || isLoading) return + + // Workflow is only supported in multirespondent mode; show message if form response mode is not multirespondent + if (settings.responseMode !== FormResponseMode.Multirespondent) { + return + } + + return ( + <> + + + Workflow + + + + Create a workflow to collect responses from multiple respondents. We + currently support up to three respondents.  + + Learn more about setting up a workflow + + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx new file mode 100644 index 0000000000..b12f4028f1 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx @@ -0,0 +1,135 @@ +import { KeyboardEventHandler, useCallback, useMemo, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { FormControl, FormErrorMessage, Stack } from '@chakra-ui/react' +import isEmail from 'validator/lib/isEmail' + +import { MultirespondentFormSettings, WorkflowType } from '~shared/types' + +import { INVALID_EMAIL_ERROR } from '~constants/validation' +import FormLabel from '~components/FormControl/FormLabel' +import Input from '~components/Input' + +import { useMutateFormSettings } from '../../mutations' + +export const WorkflowDetailsInput = ({ + settings, +}: { + settings: MultirespondentFormSettings +}): JSX.Element => { + const { mutateWorkflowSettings } = useMutateFormSettings() + + const existingSecondRespEmail = useMemo( + () => settings.workflow && settings.workflow[1]?.emails[0], + [settings], + ) + + const existingThirdRespEmail = useMemo( + () => settings.workflow && settings.workflow[2]?.emails[0], + [settings], + ) + + const existingWorkflow = useMemo( + () => + settings.workflow && settings.workflow.length > 0 + ? [...settings.workflow] + : Array(3).fill({ + emails: [], + workflow_type: WorkflowType.Static, + }), + [settings], + ) + + const validateEmail = useCallback((value: string) => { + if (!value) return true + return isEmail(value.trim()) || INVALID_EMAIL_ERROR + }, []) + + const { + register, + formState: { errors, isValid }, + getValues, + } = useForm<{ + secondRespondent: string + thirdRespondent: string + }>({ + mode: 'onChange', + defaultValues: { + secondRespondent: existingSecondRespEmail ?? '', + thirdRespondent: existingThirdRespEmail ?? '', + }, + }) + + const handleUpdateEmail = useCallback(() => { + const nextSecondEmail = getValues('secondRespondent') + const nextThirdEmail = getValues('thirdRespondent') + + const newWorkflow = [...existingWorkflow] + newWorkflow[1].emails = [nextSecondEmail] + newWorkflow[2].emails = [nextThirdEmail] + mutateWorkflowSettings.mutate(newWorkflow) + }, [getValues, existingWorkflow, mutateWorkflowSettings]) + + const handleEmailInputKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if (!isValid || e.key !== 'Enter') return + return inputRef.current?.blur() + }, + [isValid], + ) + + const handleEmailInputBlur = useCallback(() => { + if (isValid) { + return handleUpdateEmail() + } + return + }, [isValid, handleUpdateEmail]) + + const secondEmailRegister = register('secondRespondent', { + onBlur: handleEmailInputBlur, + validate: validateEmail, + }) + + const thirdEmailRegister = register('thirdRespondent', { + onBlur: handleEmailInputBlur, + validate: validateEmail, + }) + + const inputRef = useRef(null) + + return ( + + + + First respondent + + + + + + Second respondent + + + {errors.secondRespondent && ( + {errors.secondRespondent.message} + )} + + + + Third respondent + + + {errors.thirdRespondent && ( + {errors.thirdRespondent.message} + )} + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx new file mode 100644 index 0000000000..1c7871039a --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx @@ -0,0 +1,15 @@ +import { Flex, Text } from '@chakra-ui/react' + +import { SettingsUnsupportedSvgr } from '~features/admin-form/settings/svgrs/SettingsUnsupportedSvgr' + +export const WorkflowUnsupportedMsg = (): JSX.Element => { + return ( + + + Workflow is only available in multirespondent mode + + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts index 72c8d79ab4..8090383fea 100644 --- a/frontend/src/features/admin-form/settings/mutations.ts +++ b/frontend/src/features/admin-form/settings/mutations.ts @@ -9,6 +9,7 @@ import { FormResponseMode, FormSettings, FormStatus, + MultirespondentFormSettings, StorageFormSettings, } from '~shared/types/form/form' import { TwilioCredentials } from '~shared/types/twilio' @@ -40,6 +41,7 @@ import { updateFormWebhookUrl, updateGstEnabledFlag, updateTwilioCredentials, + updateWorkflowSettings, } from './SettingsService' export const useMutateFormSettings = () => { @@ -335,6 +337,19 @@ export const useMutateFormSettings = () => { }, ) + const mutateWorkflowSettings = useMutation( + (workflowSettings: MultirespondentFormSettings['workflow']) => + updateWorkflowSettings(formId, workflowSettings), + { + onSuccess: (newData) => { + handleSuccess({ + newData, + toastDescription: `Workflow settings have been updated.`, + }) + }, + onError: handleError, + }, + ) return { mutateWebhookRetries, mutateFormWebhookUrl, @@ -349,6 +364,7 @@ export const useMutateFormSettings = () => { mutateFormEsrvcId, mutateFormBusiness, mutateGST, + mutateWorkflowSettings, } } diff --git a/shared/constants/form.ts b/shared/constants/form.ts index c150902153..6fedb83657 100644 --- a/shared/constants/form.ts +++ b/shared/constants/form.ts @@ -53,6 +53,7 @@ export const STORAGE_FORM_SETTINGS_FIELDS = [ export const MULTIRESPONDENT_FORM_SETTINGS_FIELDS = [ ...FORM_SETTINGS_FIELDS, 'publicKey', + 'workflow', ] export const WEBHOOK_SETTINGS_FIELDS = ['responseMode', 'webhook'] diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index c225019638..2298bf1d2f 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -126,6 +126,17 @@ export type FormBusinessField = { gstRegNo?: string } +export enum WorkflowType { + Static = 'static', + Dynamic = 'dynamic', +} + +export type FormWorkflowSettings = Array<{ + _id?: string + workflow_type: WorkflowType + emails: string[] +}> + export interface FormBase { title: string admin: UserDto['_id'] @@ -172,6 +183,7 @@ export interface StorageFormBase extends FormBase { export interface MultirespondentFormBase extends FormBase { responseMode: FormResponseMode.Multirespondent publicKey: string + workflow?: FormWorkflowSettings } /** @@ -351,6 +363,7 @@ export type FormPermissionsDto = FormPermission[] export type PermissionsUpdateDto = FormPermission[] export type PaymentsUpdateDto = FormPaymentsField export type BusinessUpdateDto = FormBusinessField +export type WorkflowUpdateDto = FormWorkflowSettings export type PaymentsProductUpdateDto = ProductsPaymentField['products'] export type SendFormOtpResponseDto = { diff --git a/shared/types/submission.ts b/shared/types/submission.ts index 9694262acf..ed401a87eb 100644 --- a/shared/types/submission.ts +++ b/shared/types/submission.ts @@ -90,6 +90,7 @@ export const MultirespondentSubmissionBase = SubmissionBase.extend({ encryptedContent: z.string(), attachmentMetadata: z.map(z.string(), z.string()).optional(), version: z.number(), + workflowStep: z.number(), }) export type MultirespondentSubmissionBase = z.infer< @@ -150,6 +151,7 @@ export type MultirespondentSubmissionDto = SubmissionDtoBase & { //verified?: string attachmentMetadata: Record version: number + workflowStep: number } export type SubmissionDto = 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 c5fff86ad9..3e39cdbe6b 100644 --- a/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts +++ b/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts @@ -41,6 +41,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: createdDate, + workflowStep: 0, }) // Act @@ -121,6 +122,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], + workflowStep: 0, }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -163,6 +165,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], + workflowStep: 0, }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -209,6 +212,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, created: MOCK_CREATED_DATES_ASC[idx], + workflowStep: 0, }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -255,6 +259,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 1, created: MOCK_CREATED_DATES_ASC[idx], + workflowStep: 0, }), ) const validSubmissions: IMultirespondentSubmissionSchema[] = @@ -309,6 +314,7 @@ describe('Multirespondent Submission Model', () => { encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 1, + workflowStep: 0, }) const expectedSubmission = pick( validSubmission, @@ -379,6 +385,7 @@ describe('Multirespondent Submission Model', () => { encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, attachmentMetadata: { someFileName: 'some url of attachment' }, + workflowStep: 0, }) // Act @@ -401,6 +408,7 @@ describe('Multirespondent Submission Model', () => { 'encryptedContent', 'submissionType', 'version', + 'workflowStep', ) 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 f9ec8a78bb..a81bf6e397 100644 --- a/src/app/models/__tests__/submission.server.model.spec.ts +++ b/src/app/models/__tests__/submission.server.model.spec.ts @@ -83,6 +83,7 @@ describe('Submission Model', () => { encryptedSubmissionSecretKey: 'This is an encrypted secret key', encryptedContent: MOCK_ENCRYPTED_CONTENT, version: 3, + workflowStep: 0, }, MOCK_SUBMISSION_PARAMS, ) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index c486e51ebb..301e769a35 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -48,6 +48,7 @@ import { PaymentChannel, PaymentType, StorageFormSettings, + WorkflowType, } from '../../../shared/types' import { reorder } from '../../../shared/utils/immutable-array-fns' import { getApplicableIfStates } from '../../shared/util/logic' @@ -300,6 +301,32 @@ const MultirespondentFormSchema = new Schema({ type: String, required: true, }, + workflow: [ + { + workflow_type: { + type: String, + enum: Object.values(WorkflowType), + default: WorkflowType.Static, + required: true, + }, + emails: { + type: [ + { + type: String, + trim: true, + }, + ], + set: transformEmails, + validate: { + validator: (v: string[]) => { + if (!Array.isArray(v)) return false + return v.every((email) => validator.isEmail(email)) + }, + message: 'Please provide valid email addresses', + }, + }, + }, + ], }) const compileFormModel = (db: Mongoose): IFormModel => { diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index 4c55a5e637..814f6f5c62 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -467,6 +467,10 @@ export const MultirespondentSubmissionSchema = new Schema< type: Number, required: true, }, + workflowStep: { + type: Number, + required: true, + }, }) MultirespondentSubmissionSchema.statics.findSingleMetadata = function ( @@ -605,6 +609,7 @@ MultirespondentSubmissionSchema.statics.findEncryptedSubmissionById = function ( attachmentMetadata: 1, created: 1, version: 1, + workflowStep: 1, }) .exec() } diff --git a/src/app/modules/form/admin-form/admin-form.middlewares.ts b/src/app/modules/form/admin-form/admin-form.middlewares.ts index 2179fc7942..2d8cd8a41a 100644 --- a/src/app/modules/form/admin-form/admin-form.middlewares.ts +++ b/src/app/modules/form/admin-form/admin-form.middlewares.ts @@ -5,6 +5,7 @@ import { FormStatus, SettingsUpdateDto, WebhookSettingsUpdateDto, + WorkflowType, } from '../../../../../shared/types' import { verifyValidUnicodeString } from './admin-form.utils' @@ -37,6 +38,18 @@ export const updateSettingsValidator = celebrate({ gstRegNo: Joi.string().allow(''), }), payments_field: Joi.object({ gst_enabled: Joi.boolean() }), + workflow: Joi.array() + .items( + Joi.object().keys({ + _id: Joi.string(), + workflow_type: Joi.string().valid(...Object.values(WorkflowType)), + emails: Joi.alternatives().try( + Joi.array().items(Joi.string().email().allow('')), + Joi.string().email({ multiple: true }).allow(''), + ), + }), + ) + .optional(), }) .min(1) .custom((value, helpers) => verifyValidUnicodeString(value, helpers)), 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 ad716f776f..bdcb9a9f48 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -5,12 +5,17 @@ import { errAsync } from 'neverthrow' import { ErrorDto, FormAuthType, + FormWorkflowSettings, MultirespondentSubmissionDto, SubmissionType, } from '../../../../../shared/types' +import { getMultirespondentSubmissionEditPath } from '../../../../../shared/utils/urls' +import { Environment } from '../../../../../src/types' +import config from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { getMultirespondentSubmissionModel } from '../../../models/submission.server.model' import * as CaptchaMiddleware from '../../../services/captcha/captcha.middleware' +import MailService from '../../../services/mail/mail.service' import * as TurnstileMiddleware from '../../../services/turnstile/turnstile.middleware' import { Pipeline } from '../../../utils/pipeline-middleware' import { createReqMeta } from '../../../utils/request' @@ -49,6 +54,10 @@ import { createMultirespondentSubmissionDto } from './multirespondent-submission const logger = createLoggerWithLabel(module) const MultirespondentSubmission = getMultirespondentSubmissionModel(mongoose) +const appUrl = + process.env.NODE_ENV === Environment.Dev + ? config.app.feAppUrl + : config.app.appUrl const submitMultirespondentForm = async ( req: SubmitMultirespondentFormHandlerRequest, @@ -129,6 +138,7 @@ const submitMultirespondentForm = async ( const { submissionPublicKey, encryptedSubmissionSecretKey, + submissionSecretKey, encryptedContent, responseMetadata, version, @@ -145,6 +155,7 @@ const submitMultirespondentForm = async ( encryptedContent, attachmentMetadata, version, + workflowStep: 0, } return _createSubmission({ @@ -155,17 +166,21 @@ const submitMultirespondentForm = async ( // responses: , responseMetadata, submissionContent, + submissionSecretKey, + form, }) } const _createSubmission = async ({ req, res, - submissionContent, logMeta, formId, // responses, responseMetadata, + submissionContent, + submissionSecretKey, + form, }: { req: Parameters[0] res: Parameters[1] @@ -219,6 +234,72 @@ const _createSubmission = async ({ // TODO(MRF/FRM-1591): Add post-submission actions handling // return await performEncryptPostSubmissionActions(submission, responses) + + try { + await runMultirespondentWorkflow({ + nextWorkflowStep: submissionContent.workflowStep + 1, // we want to send emails to the addresses linked to the next step of the workflow + formWorkflow: form.workflow ?? [], + formTitle: form.title, + responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( + form._id, + submissionId, + { key: submissionSecretKey }, + )}`, + formId: form._id, + submissionId, + }) + } catch (err) { + logger.error({ + message: 'Send multirespondent workflow email error', + meta: { + ...logMeta, + ...createReqMeta(req), + currentWorkflowStep: submissionContent.workflowStep, + formId: form._id, + submissionId, + }, + error: err, + }) + } +} + +const runMultirespondentWorkflow = async ({ + nextWorkflowStep, + formWorkflow, + formTitle, + responseUrl, + formId, + submissionId, +}: { + nextWorkflowStep: number + formWorkflow: FormWorkflowSettings + formTitle: string + responseUrl: string + formId: string + submissionId: string +}) => { + const logMeta = { + action: 'runMultirespondentWorkflow', + formId, + submissionId, + nextWorkflowStep, + } + // Step 1: Retrieve email addresses for current workflow step + if ((formWorkflow[nextWorkflowStep]?.emails?.length ?? 0) === 0) return + const emails = formWorkflow[nextWorkflowStep].emails + // Step 2: send out workflow email + try { + await MailService.sendMRFWorkflowStepEmail({ + formTitle, + emails, + responseUrl, + }) + } catch (error) { + logger.error({ + message: 'Failed to send workflow email', + meta: { ...logMeta, emails }, + }) + } } const updateMultirespondentSubmission = async ( @@ -268,7 +349,9 @@ const updateMultirespondentSubmission = async ( submissionPublicKey, encryptedSubmissionSecretKey, encryptedContent, + submissionSecretKey, version, + workflowStep, } = encryptedPayload // Save Responses to Database @@ -294,7 +377,6 @@ const updateMultirespondentSubmission = async ( // } const submission = await MultirespondentSubmission.findById(submissionId) - if (!submission) { return res.status(StatusCodes.NOT_FOUND).json({ message: 'Not found', @@ -306,6 +388,7 @@ const updateMultirespondentSubmission = async ( submission.encryptedSubmissionSecretKey = encryptedSubmissionSecretKey submission.encryptedContent = encryptedContent submission.version = version + submission.workflowStep = workflowStep // submission.attachmentMetadata = attachmentMetadata try { @@ -342,6 +425,33 @@ const updateMultirespondentSubmission = async ( submissionId, timestamp: (submission.created || new Date()).getTime(), }) + + try { + await runMultirespondentWorkflow({ + nextWorkflowStep: workflowStep + 1, // we want to send emails to the addresses linked to the next step of the workflow + formWorkflow: form.workflow ?? [], + formTitle: form.title, + responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( + form._id, + submissionId, + { key: submissionSecretKey }, + )}`, + formId: form._id, + submissionId, + }) + } catch (err) { + logger.error({ + message: 'Send multirespondent workflow email error', + meta: { + ...logMeta, + ...createReqMeta(req), + currentWorkflowStep: workflowStep, + formId: form._id, + submissionId, + }, + error: err, + }) + } } export const handleMultirespondentSubmission = [ @@ -365,6 +475,7 @@ export const handleUpdateMultirespondentSubmission = [ MultirespondentSubmissionMiddleware.createFormsgAndRetrieveForm, MultirespondentSubmissionMiddleware.scanAndRetrieveAttachments, // EncryptSubmissionMiddleware.validateStorageSubmission, + MultirespondentSubmissionMiddleware.setCurrentWorkflowStep, MultirespondentSubmissionMiddleware.encryptSubmission, updateMultirespondentSubmission, ] as ControllerHandler[] diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts index 073c67013d..c893fadbf8 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts @@ -1,9 +1,13 @@ import { celebrate, Joi, Segments } from 'celebrate' import { NextFunction } from 'express' import { StatusCodes } from 'http-status-codes' -import { err, ok, Result, ResultAsync } from 'neverthrow' +import { err, errAsync, ok, Result, ResultAsync } from 'neverthrow' -import { BasicField, FormResponseMode } from '../../../../../shared/types' +import { + BasicField, + FormResponseMode, + SubmissionType, +} from '../../../../../shared/types' import { isDev } from '../../../../app/config/config' import { ParsedClearAttachmentResponseV3 } from '../../../../types/api' import { MultirespondentFormLoadedDto } from '../../../../types/api/multirespondent_submission' @@ -11,15 +15,20 @@ import formsgSdk from '../../../config/formsg-sdk' import { createLoggerWithLabel } from '../../../config/logger' import { createReqMeta } from '../../../utils/request' import * as FeatureFlagService from '../../feature-flags/feature-flags.service' +import { assertFormAvailable } from '../../form/admin-form/admin-form.utils' import * as FormService from '../../form/form.service' import { FormsgReqBodyExistsError } from '../encrypt-submission/encrypt-submission.errors' import { CreateFormsgAndRetrieveFormMiddlewareHandlerType } from '../encrypt-submission/encrypt-submission.types' import { DownloadCleanFileFailedError, + InvalidSubmissionTypeError, MaliciousFileDetectedError, VirusScanFailedError, } from '../submission.errors' -import { triggerVirusScanThenDownloadCleanFileChain } from '../submission.service' +import { + getEncryptedSubmissionData, + triggerVirusScanThenDownloadCleanFileChain, +} from '../submission.service' import { getEncryptedAttachmentsMapFromAttachmentsMap, mapRouteError, @@ -54,6 +63,56 @@ export const validateMultirespondentSubmissionParams = celebrate({ }), }) +export const setCurrentWorkflowStep = async ( + req: ProcessedMultirespondentSubmissionHandlerRequest, + res: Parameters[1], + next: NextFunction, +) => { + const { formId, submissionId } = req.params + if (!submissionId) { + return errAsync(new InvalidSubmissionTypeError()) + } + const logMeta = { + action: 'setCurrentWorkflowStep', + submissionId, + formId, + ...createReqMeta(req), + } + + return ( + // Step 1: Retrieve the full form object. + FormService.retrieveFullFormById(formId) + //Step 2: Check whether form is archived. + .andThen((form) => assertFormAvailable(form).map(() => form)) + // Step 3: Check whether form is multirespondent mode. + .andThen(checkFormIsMultirespondent) + // Step 4: Is multirespondent mode form, retrieve submission data. + .andThen((form) => + getEncryptedSubmissionData(form.responseMode, formId, submissionId), + ) + // Step 6: Retrieve presigned URLs for attachments. + .map((submissionData) => { + if (submissionData.submissionType !== SubmissionType.Multirespondent) { + return errAsync(new InvalidSubmissionTypeError()) + } + // Increment previous submission's workflow step by 1 to get workflow step of current submission + req.body.workflowStep = submissionData.workflowStep + 1 + return next() + }) + .mapErr((error) => { + logger.error({ + message: 'Failure retrieving encrypted submission response', + meta: logMeta, + error, + }) + + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + ) +} /** * Creates formsg namespace in req.body and populates it with featureFlags, formDef and encryptedFormDef. */ @@ -304,12 +363,10 @@ export const encryptSubmission = async ( const { encryptedContent, encryptedSubmissionSecretKey, - // submissionSecretKey, + submissionSecretKey, submissionPublicKey, } = formsgSdk.cryptoV3.encrypt(responses, formPublicKey) - //TODO(MRF/FRM-1577): Workflow here using submissionSecretKey - req.formsg.encryptedPayload = { attachments: encryptedAttachments, // responses: req.body.responses, @@ -317,7 +374,9 @@ export const encryptSubmission = async ( submissionPublicKey, encryptedSubmissionSecretKey, encryptedContent, + submissionSecretKey, version: req.body.version, + workflowStep: req.body.workflowStep, } return next() 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 5aa26f4244..88d86fc8f7 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts @@ -37,7 +37,7 @@ export type MultirespondentSubmissionMiddlewareHandlerRequest = } export type ProcessedMultirespondentSubmissionHandlerType = ControllerHandler< - { formId: string }, + { formId: string; submissionId?: string }, SubmissionResponseDto | SubmissionErrorDto, Omit & { responses: ParsedClearFormFieldResponsesV3 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 4bbf97069b..8aa2df67a9 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts @@ -29,5 +29,6 @@ export const createMultirespondentSubmissionDto = ( encryptedSubmissionSecretKey: submissionData.encryptedSubmissionSecretKey, attachmentMetadata: attachmentPresignedUrls, version: submissionData.version, + workflowStep: submissionData.workflowStep, } } diff --git a/src/app/modules/submission/receiver/receiver.types.ts b/src/app/modules/submission/receiver/receiver.types.ts index 892fbf3d4d..75598d80aa 100644 --- a/src/app/modules/submission/receiver/receiver.types.ts +++ b/src/app/modules/submission/receiver/receiver.types.ts @@ -8,6 +8,7 @@ export type ParsedMultipartForm = { responses: ResponsesType responseMetadata: ResponseMetadata version?: number + workflowStep?: number } export const isBodyVersion2AndBelow = ( diff --git a/src/app/services/mail/mail.constants.ts b/src/app/services/mail/mail.constants.ts index d52a27a020..b64b5c6499 100644 --- a/src/app/services/mail/mail.constants.ts +++ b/src/app/services/mail/mail.constants.ts @@ -23,4 +23,5 @@ export enum EmailType { PaymentConfirmation = 'Payment confirmation', PaymentOnboarding = 'Payment onboarding', IssueReportedNotification = 'Issue reported notification', + WorkflowNotification = 'Workflow notification', } diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 7651b1948c..3c760f24af 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -44,6 +44,7 @@ import { SendAutoReplyEmailsArgs, SendMailOptions, SendSingleAutoreplyMailArgs, + WorkflowEmailData, } from './mail.types' import { generateAutoreplyHtml, @@ -59,6 +60,7 @@ import { generateSmsVerificationWarningHtmlForCollab, generateSubmissionToAdminHtml, generateVerificationOtpHtml, + generateWorkflowEmail, isToFieldValid, } from './mail.utils' @@ -1013,6 +1015,41 @@ export class MailService { mailId: 'sendWarningMailForAdmin', }) } + + /** + * For MRF forms - sends a workflow notification to the valid email addresses + * @param emails the recipient email addresses + * @param formTitle the form title of the MRF form + * @param responseUrl the response url which includes the secret key + * @returns err(MailSendError) when there was an error in sending the mail + */ + sendMRFWorkflowStepEmail = ({ + emails, + formTitle, + responseUrl, + }: { + emails: string[] + formTitle: string + responseUrl: string + }): ResultAsync => { + const htmlData: WorkflowEmailData = { + formTitle: formTitle, + appName: this.#appName, + responseUrl: responseUrl, + } + return generateWorkflowEmail({ htmlData }).andThen((html) => { + const mail: MailOptions = { + to: emails, + from: this.#senderFromString, + subject: `You have been assigned to fill in ${formTitle}`, + html: html, + headers: { + [EMAIL_HEADERS.emailType]: EmailType.WorkflowNotification, + }, + } + return this.#sendNodeMail(mail, { mailId: 'workflowNotification' }) + }) + } } export default new MailService() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 224bd7be0c..b9a752e5ba 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -129,3 +129,9 @@ export type IssueReportedNotificationData = { formTitle: string formResultUrl: string } + +export type WorkflowEmailData = { + appName: string + formTitle: string + responseUrl: string +} diff --git a/src/app/services/mail/mail.utils.ts b/src/app/services/mail/mail.utils.ts index 392b0a2880..58a10c2381 100644 --- a/src/app/services/mail/mail.utils.ts +++ b/src/app/services/mail/mail.utils.ts @@ -21,6 +21,7 @@ import { IssueReportedNotificationData, PaymentConfirmationData, SubmissionToAdminHtmlData, + WorkflowEmailData, } from './mail.types' const logger = createLoggerWithLabel(module) @@ -316,3 +317,19 @@ export const generateIssueReportedNotificationHtml = ({ }) return safeRenderFile(pathToTemplate, htmlData) } + +export const generateWorkflowEmail = ({ + htmlData, +}: { + htmlData: WorkflowEmailData +}): ResultAsync => { + const pathToTemplate = `${process.cwd()}/src/app/views/templates/mrf-workflow-email.view.html` + logger.info({ + message: 'generateWorkflowEmailHtml', + meta: { + action: 'generateWorkflowEmailHtml', + pathToTemplate, + }, + }) + return safeRenderFile(pathToTemplate, htmlData) +} diff --git a/src/app/views/templates/mrf-workflow-email.view.html b/src/app/views/templates/mrf-workflow-email.view.html new file mode 100644 index 0000000000..9cecd2a769 --- /dev/null +++ b/src/app/views/templates/mrf-workflow-email.view.html @@ -0,0 +1,112 @@ + + + + + + +
+

+ +

+

+ You have been assigned to fill in <%= formTitle %>. +

+
+

Please visit the link below to fill in the form.

+
+ + + +
+
+

+ If you are having trouble with the button above, copy and paste the + link below into your browser: +

+

+ <%= responseUrl %> +

+
+
+ + diff --git a/src/types/api/multirespondent_submission.ts b/src/types/api/multirespondent_submission.ts index abb1066446..aabbd0d661 100644 --- a/src/types/api/multirespondent_submission.ts +++ b/src/types/api/multirespondent_submission.ts @@ -10,6 +10,7 @@ export type ParsedMultirespondentSubmissionBody = { responses: FieldResponsesV3 responseMetadata?: ResponseMetadata version: number + workflowStep: number } export type MultirespondentFormLoadedDto = { @@ -30,7 +31,9 @@ export type MultirespondentSubmissionDto = { submissionPublicKey: string encryptedSubmissionSecretKey: string encryptedContent: string + submissionSecretKey: string attachments?: SubmissionAttachmentsMap version: number responseMetadata?: ResponseMetadata + workflowStep: number } diff --git a/src/types/form.ts b/src/types/form.ts index db08e1fd0c..1980120e55 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -23,6 +23,7 @@ import { FormSettings, FormStartPage, FormWebhookResponseModeSettings, + FormWorkflowSettings, LogicDto, MyInfoAttribute, PublicFormDto, @@ -311,6 +312,7 @@ export type IPopulatedEmailForm = IPopulatedForm & IEmailForm export interface IMultirespondentForm extends IForm { publicKey: string emails?: never + workflow?: FormWorkflowSettings } export type IMultirespondentFormSchema = IMultirespondentForm & IFormSchema diff --git a/src/types/submission.ts b/src/types/submission.ts index 5314a5305d..7310d71cf4 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -139,6 +139,7 @@ export type MultirespondentSubmissionData = { | 'attachmentMetadata' | 'created' | 'version' + | 'workflowStep' > & Document From 92bdae944e68ed1bccfbe606cdc8d49b09726e5c Mon Sep 17 00:00:00 2001 From: Justyn Oh Date: Wed, 3 Jan 2024 13:48:32 +0800 Subject: [PATCH 4/5] fix(mrf): individual response page (#6992) --- .../ResponsesPage/storage/utils/processDecryptedContent.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/processDecryptedContent.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/processDecryptedContent.ts index 47eb934fcb..7b7a3a0ba3 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/processDecryptedContent.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/processDecryptedContent.ts @@ -129,8 +129,7 @@ export const processDecryptedContentV3 = async ( .map((ff) => { const response = responses[ff._id] if (!response) { - console.log('Unexpected empty response for field id', ff._id) - return null + return transformInputsToOutputs(ff, '') } if (response.fieldType === BasicField.Attachment) { const answer = response.answer as AttachmentFieldResponseV3 From 614d3a1af0640525a9b0b66116230d1113431c2f Mon Sep 17 00:00:00 2001 From: wanlingt Date: Wed, 3 Jan 2024 13:49:05 +0800 Subject: [PATCH 5/5] chore: bump version to v6.98.0 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++------- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 931bed2133..c3ee3679f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,27 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.98.0](https://github.com/opengovsg/FormSG/compare/v6.97.0...v6.98.0) + +> 3 January 2024 + +- fix(mrf): individual response page [`#6992`](https://github.com/opengovsg/FormSG/pull/6992) +- feat(mrf): add static workflow routing [`#6968`](https://github.com/opengovsg/FormSG/pull/6968) +- fix(deps): [Snyk] Security upgrade axios from 1.6.2 to 1.6.3 [`#6983`](https://github.com/opengovsg/FormSG/pull/6983) +- build: merge release v6.97.0 into develop [`#6990`](https://github.com/opengovsg/FormSG/pull/6990) +- fix: formendpage missing context provider [`#6989`](https://github.com/opengovsg/FormSG/pull/6989) +- build: release v6.97.0 [`#6988`](https://github.com/opengovsg/FormSG/pull/6988) +- chore: bump version to v6.98.0 [`85c5335`](https://github.com/opengovsg/FormSG/commit/85c53351552b2af6d237ff582ed36252265299e6) + #### [v6.97.0](https://github.com/opengovsg/FormSG/compare/v6.96.0...v6.97.0) +> 2 January 2024 + - fix(mrf): form duplication with MRF [`#6985`](https://github.com/opengovsg/FormSG/pull/6985) - fix: payments thank you page [`#6975`](https://github.com/opengovsg/FormSG/pull/6975) - build: merge v6.96.0 into develop [`#6974`](https://github.com/opengovsg/FormSG/pull/6974) - build: release v6.96.0 [`#6972`](https://github.com/opengovsg/FormSG/pull/6972) +- chore: bump version to v6.97.0 [`a59d430`](https://github.com/opengovsg/FormSG/commit/a59d430d4da406447390a4d3fe8e915af63d3473) #### [v6.96.0](https://github.com/opengovsg/FormSG/compare/v6.95.0...v6.96.0) @@ -55,13 +70,14 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump type-fest from 4.8.2 to 4.8.3 in /shared [`#6943`](https://github.com/opengovsg/FormSG/pull/6943) - build: merge release v6.92.0 into develop [`#6934`](https://github.com/opengovsg/FormSG/pull/6934) - build: release v6.92.0 [`#6932`](https://github.com/opengovsg/FormSG/pull/6932) +- feat: set secret key input to password type on activation modal [`#6933`](https://github.com/opengovsg/FormSG/pull/6933) +- chore: bump version to v6.92.0 [`28a8b9c`](https://github.com/opengovsg/FormSG/commit/28a8b9ca95f456c0f2c95d22813bf6d2ae1509ed) - chore: bump version to v6.93.0 [`f7e9dcf`](https://github.com/opengovsg/FormSG/commit/f7e9dcf49f5104815f11ecad6c48d6e71a1e1bf8) #### [v6.92.0](https://github.com/opengovsg/FormSG/compare/v6.91.1...v6.92.0) > 28 November 2023 -- feat: set secret key input to password type on activation modal [`#6933`](https://github.com/opengovsg/FormSG/pull/6933) - fix: add myinfo errors to error map for storage-mode submissions [`#6931`](https://github.com/opengovsg/FormSG/pull/6931) - feat(FE): set secret key input to password type [`#6930`](https://github.com/opengovsg/FormSG/pull/6930) - feat: add prefills for variable payments [`#6899`](https://github.com/opengovsg/FormSG/pull/6899) @@ -77,7 +93,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: omit isVisible property from webhook response [`#6907`](https://github.com/opengovsg/FormSG/pull/6907) - feat: charts [`#6790`](https://github.com/opengovsg/FormSG/pull/6790) - build: merge release 6.90.0 to develop [`#6914`](https://github.com/opengovsg/FormSG/pull/6914) -- chore: bump version to v6.92.0 [`28a8b9c`](https://github.com/opengovsg/FormSG/commit/28a8b9ca95f456c0f2c95d22813bf6d2ae1509ed) +- chore: bump version to v6.92.0 [`72fac02`](https://github.com/opengovsg/FormSG/commit/72fac021a92df588be577c25690b49e96796387d) #### [v6.91.1](https://github.com/opengovsg/FormSG/compare/v6.91.0...v6.91.1) @@ -109,17 +125,25 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release v6.89.1 into develop [`#6905`](https://github.com/opengovsg/FormSG/pull/6905) - chore: bump version to v6.90.0 [`c03692e`](https://github.com/opengovsg/FormSG/commit/c03692e3d9aa64afa8007dffecfd9871542f4759) -#### [v6.89.2](https://github.com/opengovsg/FormSG/compare/v6.89.0...v6.89.2) +#### [v6.89.2](https://github.com/opengovsg/FormSG/compare/v6.89.1...v6.89.2) > 17 November 2023 - fix: hotfix v6.89.1 for proper error handling in encrypt-submission middleware [`#6903`](https://github.com/opengovsg/FormSG/pull/6903) -- build: release v6.89.0 [`#6898`](https://github.com/opengovsg/FormSG/pull/6898) -- fix: remove myinfo child from storage mode [`#6901`](https://github.com/opengovsg/FormSG/pull/6901) - chore: revert commit 6869 [`efab3cf`](https://github.com/opengovsg/FormSG/commit/efab3cf844113573d758b1b2c57147d5d0656a28) - chore: bump version to 6.89.2 [`1f4e9f7`](https://github.com/opengovsg/FormSG/commit/1f4e9f70cd33ac2f015fb902de5243a4227e4981) - fix: remove error block [`fb415fc`](https://github.com/opengovsg/FormSG/commit/fb415fcd7189a90056557c242ed98f1dc10e757e) +#### [v6.89.1](https://github.com/opengovsg/FormSG/compare/v6.89.0...v6.89.1) + +> 16 November 2023 + +- build: release v6.89.0 [`#6898`](https://github.com/opengovsg/FormSG/pull/6898) +- fix: remove myinfo child from storage mode [`#6901`](https://github.com/opengovsg/FormSG/pull/6901) +- chore: bump version to 6.89.1 [`253dd25`](https://github.com/opengovsg/FormSG/commit/253dd2596844d28e5dc3caae298fc775fb1a3f75) +- fix: add error handling [`d6c4985`](https://github.com/opengovsg/FormSG/commit/d6c4985aa8e35dd2278af9b70d00d4e86a48bde1) +- fix: remove email mode from myinfo limit message [`5a45c98`](https://github.com/opengovsg/FormSG/commit/5a45c980dbe3fc8c15eacb3ff1827f3003fcbfc4) + #### [v6.89.0](https://github.com/opengovsg/FormSG/compare/v6.88.0...v6.89.0) > 15 November 2023 @@ -156,13 +180,14 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: add case for SGID MyInfo when field value is missing [`#6874`](https://github.com/opengovsg/FormSG/pull/6874) - build: merge release v6.86.0 into develop [`#6873`](https://github.com/opengovsg/FormSG/pull/6873) - build: release v6.86.0 [`#6866`](https://github.com/opengovsg/FormSG/pull/6866) +- chore: use non-testing branch for font-wqy-zenhei [`#6867`](https://github.com/opengovsg/FormSG/pull/6867) +- chore: bump version to v6.86.0 [`1eec9b6`](https://github.com/opengovsg/FormSG/commit/1eec9b63c914b56b7b10adffd03554e07fde0f3a) - chore: bump version to v6.87.0 [`5054803`](https://github.com/opengovsg/FormSG/commit/50548038804b03f30ce6d23b4d43b7a8cf7d9620) #### [v6.86.0](https://github.com/opengovsg/FormSG/compare/v6.85.1...v6.86.0) > 6 November 2023 -- chore: use non-testing branch for font-wqy-zenhei [`#6867`](https://github.com/opengovsg/FormSG/pull/6867) - chore: update credits and terms of use [`#6865`](https://github.com/opengovsg/FormSG/pull/6865) - fix: add cloudflareinsights as allowable csp [`#6864`](https://github.com/opengovsg/FormSG/pull/6864) - feat: optimise submission query [`#6863`](https://github.com/opengovsg/FormSG/pull/6863) @@ -170,7 +195,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: only render delete button if owner [`#6837`](https://github.com/opengovsg/FormSG/pull/6837) - build: merge release v6.85.1 into develop [`#6861`](https://github.com/opengovsg/FormSG/pull/6861) - fix: hotfix v6.85.1 to prevent creation of SGID_MyInfo storage mode forms [`#6860`](https://github.com/opengovsg/FormSG/pull/6860) -- chore: bump version to v6.86.0 [`1eec9b6`](https://github.com/opengovsg/FormSG/commit/1eec9b63c914b56b7b10adffd03554e07fde0f3a) +- chore: bump version to v6.86.0 [`1c827cd`](https://github.com/opengovsg/FormSG/commit/1c827cd11844649ca303bb5dc9987db5367637c9) #### [v6.85.1](https://github.com/opengovsg/FormSG/compare/v6.85.0...v6.85.1) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fcea3681b9..ad8a7f1be4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.97.0", + "version": "6.98.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.97.0", + "version": "6.98.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index f1f18d1754..44d4aa98fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.97.0", + "version": "6.98.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index 74cecaef39..c2a0c9b94f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.97.0", + "version": "6.98.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.97.0", + "version": "6.98.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index b3f8fa84ca..e17f23c425 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.97.0", + "version": "6.98.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "