diff --git a/.github/workflows/deploy-virus-scanner-production.yml b/.github/workflows/deploy-virus-scanner-production.yml index e6a3d27c74..17d9a16031 100644 --- a/.github/workflows/deploy-virus-scanner-production.yml +++ b/.github/workflows/deploy-virus-scanner-production.yml @@ -1,9 +1,5 @@ name: Deploy to production -concurrency: - group: ${{ github.ref }} - cancel-in-progress: true - permissions: id-token: write contents: read @@ -11,7 +7,7 @@ permissions: on: push: branches: - - production + - release-al2 # schedule builds for 12:00AM GMT+8 everyday to get latest virus definitions schedule: - cron: '0 16 * * *' diff --git a/.github/workflows/deploy-virus-scanner-uat.yml b/.github/workflows/deploy-virus-scanner-uat.yml index a02707d9e7..f1bdfe4b82 100644 --- a/.github/workflows/deploy-virus-scanner-uat.yml +++ b/.github/workflows/deploy-virus-scanner-uat.yml @@ -1,9 +1,5 @@ name: Deploy to uat -concurrency: - group: ${{ github.ref }} - cancel-in-progress: true - permissions: id-token: write contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6ad2fb9a..b3502ea50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,36 @@ 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.77.0](https://github.com/opengovsg/FormSG/compare/v6.76.0...v6.77.0) +#### [v6.78.0](https://github.com/opengovsg/FormSG/compare/v6.77.0...v6.78.0) + +- fix(payments): payments response page frontend ui issues [`#6730`](https://github.com/opengovsg/FormSG/pull/6730) +- fix: add check for childname when populating field value [`#6715`](https://github.com/opengovsg/FormSG/pull/6715) +- fix: remove focus from myinfo child field when form is opened [`#6729`](https://github.com/opengovsg/FormSG/pull/6729) +- fix: gogov error handling [`#6714`](https://github.com/opengovsg/FormSG/pull/6714) +- feat: BE endpoint to retrieve presigned URLs [`#6685`](https://github.com/opengovsg/FormSG/pull/6685) +- fix: inconsistent modal close buttons [`#6713`](https://github.com/opengovsg/FormSG/pull/6713) +- fix: admin payment builder design [`#6699`](https://github.com/opengovsg/FormSG/pull/6699) +- feat: improve payment confirmation mailers [`#6704`](https://github.com/opengovsg/FormSG/pull/6704) +- fix: mobile view of unsaved modal [`#6712`](https://github.com/opengovsg/FormSG/pull/6712) +- feat: add logging to check for missing Myinfo field values [`#6694`](https://github.com/opengovsg/FormSG/pull/6694) +- build: merge v6.77.0 into develop [`#6711`](https://github.com/opengovsg/FormSG/pull/6711) +- build: release v6.77.0 [`#6710`](https://github.com/opengovsg/FormSG/pull/6710) + +#### [v6.77.0](https://github.com/opengovsg/FormSG/compare/v6.76.1...v6.77.0) + +> 12 September 2023 - build: merge v6.76.1 into develop [`#6708`](https://github.com/opengovsg/FormSG/pull/6708) - feat: soften and fix storage submission validation [`#6696`](https://github.com/opengovsg/FormSG/pull/6696) - build: release v6.76.1 [`#6702`](https://github.com/opengovsg/FormSG/pull/6702) -- fix: invalid mixed digit input [`#6701`](https://github.com/opengovsg/FormSG/pull/6701) - build: merge v6.76.0 into develop [`#6700`](https://github.com/opengovsg/FormSG/pull/6700) +- chore: bump version to v6.77.0 [`6b1b9b2`](https://github.com/opengovsg/FormSG/commit/6b1b9b2ed04e6bd0cdf632c642724c906062b3ac) + +#### [v6.76.1](https://github.com/opengovsg/FormSG/compare/v6.76.0...v6.76.1) + +> 8 September 2023 + +- fix: invalid mixed digit input [`#6701`](https://github.com/opengovsg/FormSG/pull/6701) - build: release v6.76.0 [`#6698`](https://github.com/opengovsg/FormSG/pull/6698) - chore: bump version to 6.76.1 [`9e88567`](https://github.com/opengovsg/FormSG/commit/9e8856721af4e3a99b6c6fe0e31ac96414a46149) @@ -206,16 +229,11 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). > 18 July 2023 - revert: build(deps): bump winston-cloudwatch version to 6.2.0 [`#6542`](https://github.com/opengovsg/FormSG/pull/6542) -- chore: bump version to v6.66.0 [`b3a9818`](https://github.com/opengovsg/FormSG/commit/b3a9818762e334bc7126c029cdba63156bac85ed) #### [v6.66.0](https://github.com/opengovsg/FormSG/compare/v6.65.0...v6.66.0) -> 19 July 2023 +> 18 July 2023 -- fix: use proof-of-payment for payment receipt/invoice [`#6549`](https://github.com/opengovsg/FormSG/pull/6549) -- build(deps-dev): bump word-wrap from 1.2.3 to 1.2.4 in /frontend [`#6548`](https://github.com/opengovsg/FormSG/pull/6548) -- fix(deps): bump word-wrap from 1.2.3 to 1.2.4 [`#6547`](https://github.com/opengovsg/FormSG/pull/6547) -- build(deps): bump winston-cloudwatch to v6.2.0 [`#6545`](https://github.com/opengovsg/FormSG/pull/6545) - fix(deps): [Snyk] Security upgrade mongoose from 5.13.15 to 5.13.20 [`#6541`](https://github.com/opengovsg/FormSG/pull/6541) - feat: indicate if GST has been applied to a payment transaction [`#6538`](https://github.com/opengovsg/FormSG/pull/6538) - chore: reduce max payment limit [`#6543`](https://github.com/opengovsg/FormSG/pull/6543) @@ -227,7 +245,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge v6.65.0 into develop [`#6531`](https://github.com/opengovsg/FormSG/pull/6531) - build: release v6.65.0 [`#6528`](https://github.com/opengovsg/FormSG/pull/6528) - fix: add FE validation rule that email domains must be non empty [`#6529`](https://github.com/opengovsg/FormSG/pull/6529) -- chore: bump version to v6.66.0 [`0233ba8`](https://github.com/opengovsg/FormSG/commit/0233ba8018532d4978d13717fac065f81e2eb515) +- chore: bump version to v6.66.0 [`b3a9818`](https://github.com/opengovsg/FormSG/commit/b3a9818762e334bc7126c029cdba63156bac85ed) #### [v6.65.0](https://github.com/opengovsg/FormSG/compare/v6.64.0...v6.65.0) @@ -279,13 +297,14 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.60.0 to 5.60.1 in /shared [`#6487`](https://github.com/opengovsg/FormSG/pull/6487) - chore(deps-dev): bump @typescript-eslint/parser from 5.60.0 to 5.60.1 in /shared [`#6488`](https://github.com/opengovsg/FormSG/pull/6488) - build: release v6.62.0 [`#6484`](https://github.com/opengovsg/FormSG/pull/6484) +- fix: admin feedback capturing wrong local storage value [`#6485`](https://github.com/opengovsg/FormSG/pull/6485) +- chore: bump version to v6.62.0 [`b5ab2e0`](https://github.com/opengovsg/FormSG/commit/b5ab2e0d1615a5e9b0ed1829adcaf040ef700adc) - chore: bump version to v6.63.0 [`b474dc7`](https://github.com/opengovsg/FormSG/commit/b474dc76f426ddbb101ba73c50f16e507b51a437) #### [v6.62.0](https://github.com/opengovsg/FormSG/compare/v6.61.1...v6.62.0) > 26 June 2023 -- fix: admin feedback capturing wrong local storage value [`#6485`](https://github.com/opengovsg/FormSG/pull/6485) - fix: escape form title in payment invoice's html content [`#6482`](https://github.com/opengovsg/FormSG/pull/6482) - feat: admin feedback modal [`#6465`](https://github.com/opengovsg/FormSG/pull/6465) - fix: potential html script injection [`#6481`](https://github.com/opengovsg/FormSG/pull/6481) @@ -294,7 +313,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore: update issue feedback mail html [`#6478`](https://github.com/opengovsg/FormSG/pull/6478) - build: merge v6.61.0 into develop [`#6477`](https://github.com/opengovsg/FormSG/pull/6477) - fix: account for undefined response code when tracking webhook dd stats [`#6475`](https://github.com/opengovsg/FormSG/pull/6475) -- chore: bump version to v6.62.0 [`b5ab2e0`](https://github.com/opengovsg/FormSG/commit/b5ab2e0d1615a5e9b0ed1829adcaf040ef700adc) +- chore: bump version to v6.62.0 [`bafd12a`](https://github.com/opengovsg/FormSG/commit/bafd12a2bc228b606a24c0b65cd284a25d5f7521) #### [v6.61.1](https://github.com/opengovsg/FormSG/compare/v6.61.0...v6.61.1) @@ -580,22 +599,23 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: only return previous payment id [`#6210`](https://github.com/opengovsg/FormSG/pull/6210) - build: merge release v6.45.0 to develop [`#6205`](https://github.com/opengovsg/FormSG/pull/6205) - build: release v6.45.0 [`#6200`](https://github.com/opengovsg/FormSG/pull/6200) -- chore: bump version to v6.46.0 [`767e7e5`](https://github.com/opengovsg/FormSG/commit/767e7e55c3e8f058963e890b1c249704d6db1459) +- chore: update payment guide in payment unsupported msg [`#6199`](https://github.com/opengovsg/FormSG/pull/6199) +- fix(deps): bump type-fest from 3.8.0 to 3.9.0 in /shared [`#6195`](https://github.com/opengovsg/FormSG/pull/6195) +- chore: bump version to v6.45.0 [`336f4c8`](https://github.com/opengovsg/FormSG/commit/336f4c8ad072d3b582a646e21010eac4b5119ee7) +- chore: bump version to v6.46.0 [`87fda0e`](https://github.com/opengovsg/FormSG/commit/87fda0e0ec4c937350ebd013a4a9762f4acee802) +- docs: fix CHANGELOG.md to have only 1 section for v6.45.0 [`4fe9d00`](https://github.com/opengovsg/FormSG/commit/4fe9d00234bed21f0943e26cc9d7c2db96afcdd3) #### [v6.45.0](https://github.com/opengovsg/FormSG/compare/v6.44.1...v6.45.0) > 26 April 2023 -- chore: update payment guide in payment unsupported msg [`#6199`](https://github.com/opengovsg/FormSG/pull/6199) -- fix(deps): bump type-fest from 3.8.0 to 3.9.0 in /shared [`#6195`](https://github.com/opengovsg/FormSG/pull/6195) - chore: update previous payment typing and hooks [`#6191`](https://github.com/opengovsg/FormSG/pull/6191) - chore: add missed out changelog for 6.44.1 [`#6197`](https://github.com/opengovsg/FormSG/pull/6197) - build: merge release v6.44.1 to develop [`#6196`](https://github.com/opengovsg/FormSG/pull/6196) - build: release v6.44.1 [`#6193`](https://github.com/opengovsg/FormSG/pull/6193) - perf: memoize field row containers [`#6189`](https://github.com/opengovsg/FormSG/pull/6189) - build: merge release v6.44.0 to develop [`#6187`](https://github.com/opengovsg/FormSG/pull/6187) -- chore: bump version to v6.45.0 [`336f4c8`](https://github.com/opengovsg/FormSG/commit/336f4c8ad072d3b582a646e21010eac4b5119ee7) -- docs: fix CHANGELOG.md to have only 1 section for v6.45.0 [`4fe9d00`](https://github.com/opengovsg/FormSG/commit/4fe9d00234bed21f0943e26cc9d7c2db96afcdd3) +- chore: bump version to v6.45.0 [`df24b16`](https://github.com/opengovsg/FormSG/commit/df24b16f04ba595a53062a309deaf90225b3a2ad) #### [v6.44.1](https://github.com/opengovsg/FormSG/compare/v6.44.0...v6.44.1) @@ -701,7 +721,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump type-fest from 3.7.2 to 3.8.0 in /shared [`#6064`](https://github.com/opengovsg/FormSG/pull/6064) - build: merge release v6.40.0 into develop [`#6072`](https://github.com/opengovsg/FormSG/pull/6072) - build: release v6.40.0 [`#6071`](https://github.com/opengovsg/FormSG/pull/6071) +- build: release v6.39.0 [`#6068`](https://github.com/opengovsg/FormSG/pull/6068) - chore: bump version to v6.41.0 [`5055b51`](https://github.com/opengovsg/FormSG/commit/5055b51c5505aab5e8f2a00907e2ee2a9f793e88) +- chore: bump version to v6.39.0 [`081515a`](https://github.com/opengovsg/FormSG/commit/081515a2521c950dac7261f4b6c1b1b423c3af16) #### [v6.40.0](https://github.com/opengovsg/FormSG/compare/v6.39.0...v6.40.0) @@ -709,7 +731,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release v6.39.0 into develop [`#6070`](https://github.com/opengovsg/FormSG/pull/6070) - fix(AttachmentField): correctly clear attachment on upload error [`#6069`](https://github.com/opengovsg/FormSG/pull/6069) -- build: release v6.39.0 [`#6068`](https://github.com/opengovsg/FormSG/pull/6068) - chore: bump version to v6.40.0 [`d4441e2`](https://github.com/opengovsg/FormSG/commit/d4441e276a30b373d64dcc7838f1798049ccf105) #### [v6.39.0](https://github.com/opengovsg/FormSG/compare/v6.38.0...v6.39.0) @@ -727,13 +748,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: add staging-alt2 to deployment script [`#6019`](https://github.com/opengovsg/FormSG/pull/6019) - build: merge release 6.38.0 to develop [`#6017`](https://github.com/opengovsg/FormSG/pull/6017) - build: release v6.38.0 [`#6004`](https://github.com/opengovsg/FormSG/pull/6004) +- feat: more logging on fetch fallback [`#6008`](https://github.com/opengovsg/FormSG/pull/6008) - chore: bump version to v6.39.0 [`081515a`](https://github.com/opengovsg/FormSG/commit/081515a2521c950dac7261f4b6c1b1b423c3af16) #### [v6.38.0](https://github.com/opengovsg/FormSG/compare/v6.37.0...v6.38.0) -> 30 March 2023 +> 29 March 2023 -- feat: more logging on fetch fallback [`#6008`](https://github.com/opengovsg/FormSG/pull/6008) - feat: implement fetch API fallback for network error [`#5948`](https://github.com/opengovsg/FormSG/pull/5948) - fix: lowercase collaborator endpoint emails [`#5992`](https://github.com/opengovsg/FormSG/pull/5992) - chore(deps-dev): bump @typescript-eslint/parser from 5.56.0 to 5.57.0 in /shared [`#5995`](https://github.com/opengovsg/FormSG/pull/5995) @@ -742,6 +763,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump type-fest from 3.7.0 to 3.7.1 in /shared [`#5991`](https://github.com/opengovsg/FormSG/pull/5991) - build: merge v6.37.0 into develop [`#5989`](https://github.com/opengovsg/FormSG/pull/5989) - build: release v6.37.0 [`#5988`](https://github.com/opengovsg/FormSG/pull/5988) +- chore: bump version to v6.38.0 [`d199213`](https://github.com/opengovsg/FormSG/commit/d19921314cae6b79920cf5ec3fb4f396794816d6) #### [v6.37.0](https://github.com/opengovsg/FormSG/compare/v6.36.0...v6.37.0) @@ -776,6 +798,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: release v6.36.0 [`#5913`](https://github.com/opengovsg/FormSG/pull/5913) - chore: bump version to v6.37.0 [`b4d24eb`](https://github.com/opengovsg/FormSG/commit/b4d24eb14f8a80bff56d19e6fa23975d5661cce9) - chore: bump version to 6.36.1 [`faf75b5`](https://github.com/opengovsg/FormSG/commit/faf75b5a7d0d12745e9a0104c541fb66cca17258) +- chore: bump version to v6.36.0 [`d8d2b59`](https://github.com/opengovsg/FormSG/commit/d8d2b59db62d195916a8fcc3d84659dd20687de4) #### [v6.36.0](https://github.com/opengovsg/FormSG/compare/v6.35.0...v6.36.0) @@ -791,7 +814,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore(deps-dev): bump eslint-config-prettier from 8.6.0 to 8.7.0 in /shared [`#5895`](https://github.com/opengovsg/FormSG/pull/5895) - chore(deps-dev): bump eslint-plugin-simple-import-sort from 8.0.0 to 10.0.0 [`#5771`](https://github.com/opengovsg/FormSG/pull/5771) - chore(deps-dev): bump @typescript-eslint/parser from 5.54.0 to 5.54.1 in /shared [`#5892`](https://github.com/opengovsg/FormSG/pull/5892) -- chore: bump version to v6.36.0 [`d8d2b59`](https://github.com/opengovsg/FormSG/commit/d8d2b59db62d195916a8fcc3d84659dd20687de4) +- chore: bump version to v6.36.0 [`a51adf1`](https://github.com/opengovsg/FormSG/commit/a51adf1a129ff473e62f38b6836e5282afc5b83f) #### [v6.35.0](https://github.com/opengovsg/FormSG/compare/v6.34.0...v6.35.0) @@ -816,13 +839,14 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore: revert "feat: merge payment-mvp branch into develop" [`#5858`](https://github.com/opengovsg/FormSG/pull/5858) - build: merge release v6.33.0 into develop [`#5859`](https://github.com/opengovsg/FormSG/pull/5859) - build: release v6.33.0 [`#5853`](https://github.com/opengovsg/FormSG/pull/5853) +- feat: merge payment-mvp branch into develop [`#5851`](https://github.com/opengovsg/FormSG/pull/5851) +- chore: bump version to v6.33.0 [`6508dc0`](https://github.com/opengovsg/FormSG/commit/6508dc09bb519392afbc426c6b68257c4e83515d) - chore: bump version to v6.34.0 [`4624819`](https://github.com/opengovsg/FormSG/commit/46248191e9b5f69e7400ee9b03dd1decdae43e96) #### [v6.33.0](https://github.com/opengovsg/FormSG/compare/v6.32.0...v6.33.0) > 28 February 2023 -- feat: merge payment-mvp branch into develop [`#5851`](https://github.com/opengovsg/FormSG/pull/5851) - chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.52.0 to 5.54.0 in /shared [`#5847`](https://github.com/opengovsg/FormSG/pull/5847) - chore(deps-dev): bump @typescript-eslint/parser from 5.53.0 to 5.54.0 in /shared [`#5846`](https://github.com/opengovsg/FormSG/pull/5846) - fix(deps): bump libphonenumber-js from 1.10.20 to 1.10.21 in /shared [`#5841`](https://github.com/opengovsg/FormSG/pull/5841) @@ -831,7 +855,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release v6.32.0 into develop [`#5833`](https://github.com/opengovsg/FormSG/pull/5833) - [Snyk] Security upgrade @aws-sdk/client-cloudwatch-logs from 3.241.0 to 3.276.0 [`#5835`](https://github.com/opengovsg/FormSG/pull/5835) - build: release v6.32.0 [`#5832`](https://github.com/opengovsg/FormSG/pull/5832) -- chore: bump version to v6.33.0 [`6508dc0`](https://github.com/opengovsg/FormSG/commit/6508dc09bb519392afbc426c6b68257c4e83515d) +- chore: bump version to v6.33.0 [`2e09f10`](https://github.com/opengovsg/FormSG/commit/2e09f1081a6c9f605f3e9ed179330b9b1b162d5e) #### [v6.32.0](https://github.com/opengovsg/FormSG/compare/v6.31.0...v6.32.0) @@ -955,13 +979,14 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump libphonenumber-js from 1.10.15 to 1.10.17 in /shared [`#5690`](https://github.com/opengovsg/FormSG/pull/5690) - fix(deps): bump type-fest from 3.5.0 to 3.5.1 in /shared [`#5687`](https://github.com/opengovsg/FormSG/pull/5687) - build: release v6.27.0 [`#5684`](https://github.com/opengovsg/FormSG/pull/5684) +- fix: add submission time to end page, returned from server [`#5672`](https://github.com/opengovsg/FormSG/pull/5672) +- chore: bump version to v6.27.0 [`86b31d4`](https://github.com/opengovsg/FormSG/commit/86b31d4bd6959171842112bd7d8227841cbcda66) - chore: bump version to v6.28.0 [`f982046`](https://github.com/opengovsg/FormSG/commit/f982046d76bfb3b2d8ceec36daaf23dde3ac165b) #### [v6.27.0](https://github.com/opengovsg/FormSG/compare/v6.26.0...v6.27.0) > 4 January 2023 -- fix: add submission time to end page, returned from server [`#5672`](https://github.com/opengovsg/FormSG/pull/5672) - chore: log formAuthType when redirecting user to sp/cp/myinfo/sgid [`#5682`](https://github.com/opengovsg/FormSG/pull/5682) - chore(deps-dev): bump @typescript-eslint/parser from 5.47.1 to 5.48.0 in /shared [`#5680`](https://github.com/opengovsg/FormSG/pull/5680) - chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.47.1 to 5.48.0 in /shared [`#5679`](https://github.com/opengovsg/FormSG/pull/5679) @@ -973,7 +998,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: backport v6.26.0 into develop [`#5669`](https://github.com/opengovsg/FormSG/pull/5669) - fix(deps): bump zod from 3.19.1 to 3.20.2 [`#5668`](https://github.com/opengovsg/FormSG/pull/5668) - build: release v6.26.0 [`#5666`](https://github.com/opengovsg/FormSG/pull/5666) -- chore: bump version to v6.27.0 [`86b31d4`](https://github.com/opengovsg/FormSG/commit/86b31d4bd6959171842112bd7d8227841cbcda66) +- chore: bump version to v6.27.0 [`3e2a28b`](https://github.com/opengovsg/FormSG/commit/3e2a28b6394b78e02eca138cfe781337cdee10b1) #### [v6.26.0](https://github.com/opengovsg/FormSG/compare/v6.25.0...v6.26.0) @@ -1007,6 +1032,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release v6.25.0 into develop [`#5632`](https://github.com/opengovsg/FormSG/pull/5632) - build: release v6.25.0 [`#5630`](https://github.com/opengovsg/FormSG/pull/5630) - chore: bump version to v6.26.0 [`8a0b8e2`](https://github.com/opengovsg/FormSG/commit/8a0b8e2df75af8d9f59df5feee03f50211c4339d) +- chore: bump version to v6.25.0 [`8da99f6`](https://github.com/opengovsg/FormSG/commit/8da99f68dbb1ccbebda72ad95729aaf97e93a321) #### [v6.25.0](https://github.com/opengovsg/FormSG/compare/v6.24.1...v6.25.0) @@ -1021,20 +1047,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release 6.24.1 into develop [`#5622`](https://github.com/opengovsg/FormSG/pull/5622) - build: Release v6.24.1 hotfix [`#5621`](https://github.com/opengovsg/FormSG/pull/5621) - build: merge release 6.24.0 into develop [`#5619`](https://github.com/opengovsg/FormSG/pull/5619) -- chore: bump version to v6.25.0 [`8da99f6`](https://github.com/opengovsg/FormSG/commit/8da99f68dbb1ccbebda72ad95729aaf97e93a321) +- chore: bump version to v6.25.0 [`6080687`](https://github.com/opengovsg/FormSG/commit/6080687d7d8ee7ce343ac6602ae2853ffde20e62) #### [v6.24.1](https://github.com/opengovsg/FormSG/compare/v6.24.0...v6.24.1) > 23 December 2022 - build: release v6.24.0 [`#5615`](https://github.com/opengovsg/FormSG/pull/5615) -- Revert "fix(deps): bump sqs-producer from 2.1.0 to 3.1.0 (#5590)" [`df3e488`](https://github.com/opengovsg/FormSG/commit/df3e488591c29d63c7c4853fe77b10ffb6f724b7) -- chore: bump version to v6.24.1 [`e2fd68c`](https://github.com/opengovsg/FormSG/commit/e2fd68cd0ca6ae62318ea33ffd03afdbe1594bd1) - -#### [v6.24.0](https://github.com/opengovsg/FormSG/compare/v6.23.3...v6.24.0) - -> 23 December 2022 - - feat: initialise datadog in head [`#5571`](https://github.com/opengovsg/FormSG/pull/5571) - build: merge 6.23.3 into develop [`#5614`](https://github.com/opengovsg/FormSG/pull/5614) - ci: separate e2e and backend ci flows [`#5483`](https://github.com/opengovsg/FormSG/pull/5483) @@ -1044,11 +1063,15 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump @sentry/browser from 7.21.1 to 7.28.0 [`#5607`](https://github.com/opengovsg/FormSG/pull/5607) - build: merge release 6.23.2 back to develop [`#5606`](https://github.com/opengovsg/FormSG/pull/5606) - chore(deps-dev): bump regenerator from 0.14.9 to 0.14.10 [`#5608`](https://github.com/opengovsg/FormSG/pull/5608) +- build: Release v6.23.2 hotfix [`#5602`](https://github.com/opengovsg/FormSG/pull/5602) - test: fix race condition in field schema test [`#5604`](https://github.com/opengovsg/FormSG/pull/5604) - fix: avoid using merge to set query after reordering fields [`#5605`](https://github.com/opengovsg/FormSG/pull/5605) - chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.46.1 to 5.47.0 in /shared [`#5592`](https://github.com/opengovsg/FormSG/pull/5592) - fix(deps): bump sqs-producer from 2.1.0 to 3.1.0 [`#5590`](https://github.com/opengovsg/FormSG/pull/5590) - feat: push static assets to s3 [`#5595`](https://github.com/opengovsg/FormSG/pull/5595) +- fix: return 404 for unmatched static assets [`#5579`](https://github.com/opengovsg/FormSG/pull/5579) +- test: fix selectors for tests [`#5597`](https://github.com/opengovsg/FormSG/pull/5597) +- feat: push static assets to s3 [`#5595`](https://github.com/opengovsg/FormSG/pull/5595) - fix(deps): bump @aws-sdk/client-cloudwatch-logs from 3.218.0 to 3.234.0 [`#5599`](https://github.com/opengovsg/FormSG/pull/5599) - fix: return 404 for unmatched static assets [`#5579`](https://github.com/opengovsg/FormSG/pull/5579) - chore: merge v6.23.1 to develop [`#5594`](https://github.com/opengovsg/FormSG/pull/5594) @@ -1059,14 +1082,23 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump libphonenumber-js from 1.10.14 to 1.10.15 [`#5553`](https://github.com/opengovsg/FormSG/pull/5553) - fix(deps): bump moment-timezone from 0.5.39 to 0.5.40 [`#5586`](https://github.com/opengovsg/FormSG/pull/5586) - chore(deps-dev): bump @playwright/test from 1.27.1 to 1.29.0 [`#5585`](https://github.com/opengovsg/FormSG/pull/5585) +- * fix: trim email input in frontend and backend [`#5581`](https://github.com/opengovsg/FormSG/pull/5581) +- fix(deps): bump formsg-javascript-sdk from 0.9.0 to 0.10.0 [`#5578`](https://github.com/opengovsg/FormSG/pull/5578) - fix(deps): bump zod from 3.19.1 to 3.20.2 in /shared [`#5562`](https://github.com/opengovsg/FormSG/pull/5562) - fix(deps): bump type-fest from 3.3.0 to 3.4.0 in /shared [`#5563`](https://github.com/opengovsg/FormSG/pull/5563) - fix(deps): bump formsg-javascript-sdk from 0.9.0 to 0.10.0 [`#5578`](https://github.com/opengovsg/FormSG/pull/5578) - fix: remove unecessary import [`#5576`](https://github.com/opengovsg/FormSG/pull/5576) +- Revert "fix(deps): bump sqs-producer from 2.1.0 to 3.1.0 (#5590)" [`df3e488`](https://github.com/opengovsg/FormSG/commit/df3e488591c29d63c7c4853fe77b10ffb6f724b7) +- chore: bump version to 6.23.2 [`1256707`](https://github.com/opengovsg/FormSG/commit/1256707db95a30486c2ccfc731d9ff6cbdb7dc40) +- chore: bump version to v6.24.0 [`13fa4f6`](https://github.com/opengovsg/FormSG/commit/13fa4f6eb7854ef91e632d8e316d180e57bb759b) + +#### [v6.24.0](https://github.com/opengovsg/FormSG/compare/v6.23.3...v6.24.0) + +> 16 December 2022 + - feat: upgrade axios to 1.2.1 [`#5568`](https://github.com/opengovsg/FormSG/pull/5568) - chore: merge v6.23.0 into develop [`#5574`](https://github.com/opengovsg/FormSG/pull/5574) -- chore: bump version to v6.24.0 [`13fa4f6`](https://github.com/opengovsg/FormSG/commit/13fa4f6eb7854ef91e632d8e316d180e57bb759b) -- Revert "fix(deps): bump sqs-consumer from 5.7.0 to 6.1.0 (#5591)" [`088902d`](https://github.com/opengovsg/FormSG/commit/088902d74fe8fde365859342aaacc7b735a777a3) +- chore: bump version to v6.24.0 [`ef21f76`](https://github.com/opengovsg/FormSG/commit/ef21f76703d53d73e61d36e913167be37e48cdf3) #### [v6.23.3](https://github.com/opengovsg/FormSG/compare/v6.23.2...v6.23.3) @@ -1157,13 +1189,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - chore: decommission SAML support for Singpass / Corppass [`#5469`](https://github.com/opengovsg/FormSG/pull/5469) - chore: update chromatic GA to only run in PRs [`#5488`](https://github.com/opengovsg/FormSG/pull/5488) - build: release v6.19.0 [`#5481`](https://github.com/opengovsg/FormSG/pull/5481) -- chore: bump version to v6.20.0 [`54131bd`](https://github.com/opengovsg/FormSG/commit/54131bd9c354fbb9aa478ca871817c34b1544aaa) -- Merge pull request #5490 from opengovsg/release-al2 [`bf0984e`](https://github.com/opengovsg/FormSG/commit/bf0984e8ae74382fd455c0821b16976793990932) - -#### [v6.19.0](https://github.com/opengovsg/FormSG/compare/v6.18.5...v6.19.0) - -> 29 November 2022 - - feat: add public form `/use-template` redirection to admin form template page [`#5486`](https://github.com/opengovsg/FormSG/pull/5486) - fix: remove form title special characters validation [`#5485`](https://github.com/opengovsg/FormSG/pull/5485) - chore: merge hotfix release v6.18.5 to develop [`#5480`](https://github.com/opengovsg/FormSG/pull/5480) @@ -1178,15 +1203,28 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: extend MyInfo login expiry to match env var [`#5464`](https://github.com/opengovsg/FormSG/pull/5464) - chore: merge hotfix release v6.18.4 back to develop [`#5472`](https://github.com/opengovsg/FormSG/pull/5472) - feat: improve admin storage mode response printability [`#5460`](https://github.com/opengovsg/FormSG/pull/5460) +- fix: update field validators with more specific type guarantees [`#5468`](https://github.com/opengovsg/FormSG/pull/5468) +- build: hotfix release v6.18.4 [`#5470`](https://github.com/opengovsg/FormSG/pull/5470) +- fix: correctly perform logic validation on MyInfo prefilled fields [`#5467`](https://github.com/opengovsg/FormSG/pull/5467) +- fix: trim dropdown option answer in backend and in angularjs frontend [`#5466`](https://github.com/opengovsg/FormSG/pull/5466) - chore(deps-dev): bump @babel/preset-env from 7.19.4 to 7.20.2 [`#5463`](https://github.com/opengovsg/FormSG/pull/5463) - test: add email submission e2e tests [`#5162`](https://github.com/opengovsg/FormSG/pull/5162) - feat: allow special chars in form title [`#5436`](https://github.com/opengovsg/FormSG/pull/5436) +- fix: release v6.18.3 [`#5459`](https://github.com/opengovsg/FormSG/pull/5459) - chore(deps-dev): bump husky from 8.0.1 to 8.0.2 [`#5457`](https://github.com/opengovsg/FormSG/pull/5457) - fix(docker-compose): change ports from 5000 to 5001 [`#5455`](https://github.com/opengovsg/FormSG/pull/5455) - chore(deps-dev): bump @types/lodash from 4.14.189 to 4.14.190 in /shared [`#5454`](https://github.com/opengovsg/FormSG/pull/5454) - chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.43.0 to 5.44.0 [`#5453`](https://github.com/opengovsg/FormSG/pull/5453) - fix: update max dimension for image uploads [`#5451`](https://github.com/opengovsg/FormSG/pull/5451) - fix(deps): bump @sentry/browser from 7.17.3 to 7.20.1 [`#5449`](https://github.com/opengovsg/FormSG/pull/5449) +- chore: bump version to v6.20.0 [`54131bd`](https://github.com/opengovsg/FormSG/commit/54131bd9c354fbb9aa478ca871817c34b1544aaa) +- Merge pull request #5490 from opengovsg/release-al2 [`bf0984e`](https://github.com/opengovsg/FormSG/commit/bf0984e8ae74382fd455c0821b16976793990932) +- fix(deps): bump @aws-sdk/client-cloudwatch-logs to 3.216.0 [`ac812ec`](https://github.com/opengovsg/FormSG/commit/ac812ecdd14b11f9500e3d95aa75050be40d8e24) + +#### [v6.19.0](https://github.com/opengovsg/FormSG/compare/v6.18.5...v6.19.0) + +> 22 November 2022 + - fix: disable submission in template mode [`#5443`](https://github.com/opengovsg/FormSG/pull/5443) - fix(docker-compose): include US and SG SES env vars [`#5442`](https://github.com/opengovsg/FormSG/pull/5442) - feat: use-template for forms [`#5377`](https://github.com/opengovsg/FormSG/pull/5377) @@ -1205,7 +1243,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump aws-sdk from 2.1251.0 to 2.1255.0 [`#5415`](https://github.com/opengovsg/FormSG/pull/5415) - fix(deps): bump loader-utils from 1.4.0 to 1.4.2 [`#5416`](https://github.com/opengovsg/FormSG/pull/5416) - chore(deps-dev): bump eslint-plugin-jest from 27.1.3 to 27.1.5 [`#5414`](https://github.com/opengovsg/FormSG/pull/5414) -- chore: bump version to v6.19.0 [`f245c66`](https://github.com/opengovsg/FormSG/commit/f245c66f1dbb133429101574889e55b479591061) +- chore: bump version to v6.19.0 [`04762a2`](https://github.com/opengovsg/FormSG/commit/04762a268542a70f3b9d10e1f8c7f273d32ac9bc) - Merge pull request #5435 from opengovsg/release-al2 [`6023bac`](https://github.com/opengovsg/FormSG/commit/6023bac35189418bac8b1bb59cea53661df7fb76) #### [v6.18.5](https://github.com/opengovsg/FormSG/compare/v6.18.4...v6.18.5) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1acc77c2b7..c33dafffc6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.77.0", + "version": "6.78.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.77.0", + "version": "6.78.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index e5f59edd00..4bd7ef9097 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.77.0", + "version": "6.78.0", "homepage": ".", "private": true, "dependencies": { diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductItem.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductItem.tsx new file mode 100644 index 0000000000..e640a44048 --- /dev/null +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductItem.tsx @@ -0,0 +1,218 @@ +import { useMemo } from 'react' +import { BiDotsHorizontalRounded, BiEditAlt, BiTrash } from 'react-icons/bi' +import { + Box, + ButtonGroup, + Divider, + Drawer, + DrawerBody, + DrawerContent, + DrawerOverlay, + Flex, + Table, + TableContainer, + Tbody, + Td, + Text, + Tr, + useDisclosure, +} from '@chakra-ui/react' + +import { Product } from '~shared/types' +import { centsToDollars } from '~shared/utils/payments' + +import { useIsMobile } from '~hooks/useIsMobile' +import Button, { ButtonProps } from '~components/Button' +import IconButton from '~components/IconButton' + +export const ProductItem = ({ + product, + onEditClick, + onDeleteClick, + isDisabled, +}: { + product: Product + onEditClick: () => void + onDeleteClick: () => void + isDisabled: boolean +}) => { + const isMobile = useIsMobile() + return ( + <> + + + + + + {product.name} + + {isMobile && ( + + )} + + + + + + {product.multi_qty && ( + + )} + +
+
+
+ + {!isMobile && ( + + )} +
+
+ + ) +} + +const DesktopProductItemButtonGroup = ({ + isDisabled, + onEditClick, + onDeleteClick, +}: { + isDisabled: boolean + onEditClick: () => void + onDeleteClick: () => void +}) => { + return ( + + } + color="primary.500" + aria-label={'Edit'} + onClick={onEditClick} + /> + } + color="danger.500" + aria-label={'Delete'} + onClick={onDeleteClick} + /> + + ) +} + +const MobileProductItemMenu = ({ + isDisabled, + onEditClick, + onDeleteClick, +}: { + isDisabled: boolean + onEditClick: () => void + onDeleteClick: () => void +}) => { + const { isOpen, onOpen, onClose } = useDisclosure() + + const buttonProps: Partial = useMemo( + () => ({ + isFullWidth: true, + iconSpacing: '1rem', + justifyContent: 'flex-start', + textStyle: 'body-1', + }), + [], + ) + + return ( + + } + onClick={onOpen} + size="xs" + isDisabled={isDisabled} + /> + + + + + + + + + + + + + + ) +} + +const ProductItemTableContent = ({ + label, + value, +}: { + label: string + value: string +}) => { + return ( + + + {label} + + + {value} + + + ) +} diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductModal.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductModal.tsx index 0776a52b65..069303ff22 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductModal.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductModal.tsx @@ -1,20 +1,18 @@ import { Controller, RegisterOptions, useForm } from 'react-hook-form' import { Box, - Button, - ButtonGroup, Divider, Flex, FormControl, Modal, ModalBody, - ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Skeleton, Stack, + useBreakpointValue, } from '@chakra-ui/react' import { Product, StorageFormSettings } from '~shared/types' @@ -24,9 +22,12 @@ import { formatCurrency, } from '~shared/utils/payments' +import { useIsMobile } from '~hooks/useIsMobile' +import Button from '~components/Button' import FormErrorMessage from '~components/FormControl/FormErrorMessage' import FormLabel from '~components/FormControl/FormLabel' import Input from '~components/Input' +import { ModalCloseButton } from '~components/Modal' import MoneyInput from '~components/MoneyInput' import Toggle from '~components/Toggle' @@ -78,6 +79,8 @@ export const ProductModal = ({ mode: 'all', }) + const isMobile = useIsMobile() + const { data: { maxPaymentAmountCents = Number.MAX_SAFE_INTEGER, @@ -180,16 +183,21 @@ export const ProductModal = ({ return true }, } + const modalSize = useBreakpointValue({ + base: 'mobile', + xs: 'mobile', + md: 'md', + }) return ( - + {product ? 'Edit' : 'Add'} product/service }> - + - - - - Amount - - - - ( - { - field.onChange(e) - trigger([MIN_QTY_KEY, MAX_QTY_KEY, DISPLAY_AMOUNT_KEY]) - }} - /> - )} - /> - - {errors.display_amount?.message} - - - - + + + + + Amount + + + + ( + { + field.onChange(e) + trigger([ + MIN_QTY_KEY, + MAX_QTY_KEY, + DISPLAY_AMOUNT_KEY, + ]) + }} + /> + )} + /> + + {errors.display_amount?.message} + + + + + - - - + diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductServiceBox.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductServiceBox.tsx index 0f20b6b0e4..6603dde279 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductServiceBox.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/PaymentsInputPanel/ProductServiceBox.tsx @@ -1,9 +1,8 @@ import { useState } from 'react' import { FormState } from 'react-hook-form' -import { BiEditAlt, BiPlus, BiTrash } from 'react-icons/bi' +import { BiPlus } from 'react-icons/bi' import { Box, - ButtonGroup, Divider, Flex, FormControl, @@ -13,68 +12,21 @@ import { } from '@chakra-ui/react' import { FormPaymentsField, Product } from '~shared/types' -import { centsToDollars } from '~shared/utils/payments' import Button from '~components/Button' import FormLabel from '~components/FormControl/FormLabel' -import IconButton from '~components/IconButton' import { useMutateFormPage } from '~features/admin-form/common/mutations' import { dataSelector, usePaymentStore } from '../usePaymentStore' import { FormPaymentsInput } from './PaymentsInputPanel' +import { ProductItem } from './ProductItem' import { ProductModal } from './ProductModal' // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {} -const ProductItem = ({ - product, - onEditClick, - onDeleteClick, - isDisabled, -}: { - product: Product - onEditClick: () => void - onDeleteClick: () => void - isDisabled: boolean -}) => { - return ( - <> - - - - - {product.name} - - - ${centsToDollars(product.amount_cents)} - - - - - } - color="primary.500" - aria-label={'Edit'} - onClick={onEditClick} - /> - } - color="danger.500" - aria-label={'Delete'} - onClick={onDeleteClick} - /> - - - - - ) -} - const AddProductButton = ({ isDisabled, onClick, @@ -238,7 +190,7 @@ export const ProductServiceBox = ({ isDisabled={!paymentIsEnabled} isRequired > - Product/service name + Product/service ( - + {name} @@ -101,14 +101,19 @@ const PayoutDataHeader = ({ Depending on payment method, payouts happen 1 - 3 working days after a respondent makes payment." > - + - - {label} + + {label} {rightIcon && } - + ) @@ -118,14 +123,19 @@ const PaymentDataHeader = ({ colorScheme, rightIcon, }: PaymentDataHeaderProps) => ( - + {name} - - {label} + + {label} {rightIcon && } - + ) diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTable.tsx b/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTable.tsx index d868a856fd..b1fb84e8e5 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTable.tsx +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/ResponsesTable.tsx @@ -24,21 +24,21 @@ const RESPONSE_TABLE_COLUMNS: Column[] = [ { Header: '#', accessor: 'number', - minWidth: 20, // minWidth is only used as a limit for resizing - width: 20, // width is used for both the flex-basis and flex-grow + minWidth: 80, // minWidth is only used as a limit for resizing + width: 80, // width is used for both the flex-basis and flex-grow maxWidth: 100, // maxWidth is only used as a limit for resizing }, { Header: 'Response ID', accessor: 'refNo', - minWidth: 100, - width: 100, + minWidth: 300, + width: 300, maxWidth: 240, // maxWidth is only used as a limit for resizing }, { Header: 'Timestamp', accessor: 'submissionTime', - minWidth: 200, + minWidth: 250, width: 250, disableResizing: true, }, @@ -52,8 +52,8 @@ const PAYMENT_COLUMNS: Column[] = [ } return payments.email }, - minWidth: 50, - width: 150, + minWidth: 250, + width: 250, }, { @@ -64,8 +64,8 @@ const PAYMENT_COLUMNS: Column[] = [ } return `${centsToDollars(payments.paymentAmt)}` }, - minWidth: 50, - width: 75, + minWidth: 150, + width: 150, }, { @@ -80,15 +80,15 @@ const PAYMENT_COLUMNS: Column[] = [ return `${centsToDollars(payments.transactionFee)}` }, - minWidth: 50, - width: 75, + minWidth: 150, + width: 150, }, { Header: 'Net Amount (S$)', // (amt they receive in bank) accessor: ({ payments }) => getNetAmount(payments), - minWidth: 50, - width: 75, + minWidth: 150, + width: 150, }, { @@ -99,8 +99,9 @@ const PAYMENT_COLUMNS: Column[] = [ } return payments.payoutDate }, - minWidth: 50, - width: 150, + minWidth: 200, + width: 200, + disableResizing: true, }, ] @@ -184,7 +185,6 @@ export const ResponsesTable = () => { as="div" variant="solid" colorScheme="secondary" - width={isPaymentsForm ? '100vw' : undefined} {...getTableProps()} > diff --git a/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts b/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts index 74d0da7098..5d2bd46080 100644 --- a/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts +++ b/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts @@ -55,7 +55,7 @@ export const getPaymentDataView = ( { key: 'email', name: 'Payer', value: payment.email }, { key: 'receiptUrl', - name: 'Invoice', + name: 'Proof of Payment', value: getFullInvoiceDownloadUrl(hostOrigin, formId, payment.id), }, diff --git a/frontend/src/features/admin-form/share/ShareFormModal.tsx b/frontend/src/features/admin-form/share/ShareFormModal.tsx index 0553d25567..9bdaf13d02 100644 --- a/frontend/src/features/admin-form/share/ShareFormModal.tsx +++ b/frontend/src/features/admin-form/share/ShareFormModal.tsx @@ -6,6 +6,7 @@ import { Divider, FormControl, FormHelperText, + HStack, InputGroup, InputLeftAddon, InputRightElement, @@ -24,8 +25,13 @@ import { useBreakpointValue, } from '@chakra-ui/react' import dedent from 'dedent' +import { StatusCodes } from 'http-status-codes' -import { featureFlags } from '~shared/constants' +import { + featureFlags, + GO_ALREADY_EXIST_ERROR_MESSAGE, + GO_VALIDATION_ERROR_MESSAGE, +} from '~shared/constants' import { BxsCheckCircle, BxsErrorCircle } from '~/assets/icons' @@ -34,6 +40,7 @@ import { ADMINFORM_SETTINGS_SUBROUTE, ADMINFORM_USETEMPLATE_ROUTE, } from '~constants/routes' +import { HttpError } from '~services/ApiService' import Button from '~components/Button' import FormLabel from '~components/FormControl/FormLabel' import IconButton from '~components/IconButton' @@ -59,7 +66,7 @@ type goLinkHelperTextType = { const goLinkClaimSuccessHelperText: goLinkHelperTextType = { color: 'success.700', - icon: , + icon: , text: ( You have successfully claimed this link. This link will appear in your{' '} @@ -70,10 +77,20 @@ const goLinkClaimSuccessHelperText: goLinkHelperTextType = { ), } -const goLinkClaimFailureHelperText: goLinkHelperTextType = { - color: 'danger.500', - icon: , - text: Short link is already in use., +const GO_VALIDATION_FAILED_HELPER_TEXT = + 'Short links should only consist of lowercase letters, numbers and hyphens.' +const GO_ALREADY_EXIST_HELPER_TEXT = 'Short link is already in use.' +const GO_UNEXPECTED_ERROR_HELPER_TEXT = + 'Something went wrong. Try refreshing this page. If this issue persists, contact support@form.gov.sg.' + +const getGoLinkClaimFailureHelperText = ( + text: string, +): goLinkHelperTextType => { + return { + color: 'danger.500', + icon: , + text: {text}, + } } export interface ShareFormModalProps { @@ -201,7 +218,22 @@ export const ShareFormModal = ({ return } catch (err) { setClaimGoLoading(false) - setGoLinkHelperText(goLinkClaimFailureHelperText) + + let errMessage = GO_UNEXPECTED_ERROR_HELPER_TEXT + + if (err instanceof HttpError && err.code === StatusCodes.BAD_REQUEST) + switch (err.message) { + case GO_VALIDATION_ERROR_MESSAGE: + errMessage = GO_VALIDATION_FAILED_HELPER_TEXT + break + case GO_ALREADY_EXIST_ERROR_MESSAGE: + errMessage = GO_ALREADY_EXIST_HELPER_TEXT + break + default: + // will use unexpected error text + } + + setGoLinkHelperText(getGoLinkClaimFailureHelperText(errMessage)) return } }, [user, claimGoLinkMutation, goLinkSuffixInput, formId]) @@ -370,11 +402,12 @@ export const ShareFormModal = ({ )} {goLinkHelperText && ( + // padding on icon box to emulate padding from - - {goLinkHelperText.icon} + + {goLinkHelperText.icon} {goLinkHelperText.text} - + )} diff --git a/frontend/src/features/link-shortener/GoGovService.ts b/frontend/src/features/link-shortener/GoGovService.ts index 4b2a1cd9ab..ece2655504 100644 --- a/frontend/src/features/link-shortener/GoGovService.ts +++ b/frontend/src/features/link-shortener/GoGovService.ts @@ -14,7 +14,7 @@ export const claimGoLink = async ( linkSuffix: string, formId: string, adminEmail: string, -): Promise => { +): Promise => { return ApiService.post(`${ADMIN_FORM_ENDPOINT}/${formId}/${GOGOV_ENDPOINT}`, { linkSuffix, adminEmail, diff --git a/frontend/src/features/public-form/components/DuplicatePaymentModal/DuplicatePaymentModal.tsx b/frontend/src/features/public-form/components/DuplicatePaymentModal/DuplicatePaymentModal.tsx index 623fefaac8..f9a9ee6ead 100644 --- a/frontend/src/features/public-form/components/DuplicatePaymentModal/DuplicatePaymentModal.tsx +++ b/frontend/src/features/public-form/components/DuplicatePaymentModal/DuplicatePaymentModal.tsx @@ -4,7 +4,6 @@ import { Link, Modal, ModalBody, - ModalCloseButton, ModalContent, ModalFooter, ModalHeader, @@ -15,6 +14,7 @@ import { import { useIsMobile } from '~hooks/useIsMobile' import ButtonGroup from '~components/ButtonGroup' +import { ModalCloseButton } from '~components/Modal' import { getPaymentPageUrl } from '~features/public-form/utils/urls' @@ -49,8 +49,10 @@ export const DuplicatePaymentModal = ({ - {!isMobile && } - Proceed to pay again? + + + Proceed to pay again? + diff --git a/frontend/src/features/public-form/components/FormPaymentModal/FormPaymentModal.tsx b/frontend/src/features/public-form/components/FormPaymentModal/FormPaymentModal.tsx index ce88f94242..7f85855ee4 100644 --- a/frontend/src/features/public-form/components/FormPaymentModal/FormPaymentModal.tsx +++ b/frontend/src/features/public-form/components/FormPaymentModal/FormPaymentModal.tsx @@ -2,7 +2,6 @@ import { MouseEvent, MouseEventHandler } from 'react' import { Modal, ModalBody, - ModalCloseButton, ModalContent, ModalFooter, ModalHeader, @@ -12,6 +11,7 @@ import { import { useIsMobile } from '~hooks/useIsMobile' import Button from '~components/Button' import ButtonGroup from '~components/ButtonGroup' +import { ModalCloseButton } from '~components/Modal' type FormPaymentModalProps = { onSubmit: MouseEventHandler | undefined @@ -39,8 +39,10 @@ export const FormPaymentModal = ({ - {!isMobile && } - You are about to make payment + + + You are about to make payment + Please ensure that your form information is accurate. You will not be able to edit your form after you proceed. diff --git a/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentResumeModal.tsx b/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentResumeModal.tsx index 4cc62eba37..939a5d0752 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentResumeModal.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentResumeModal.tsx @@ -14,6 +14,7 @@ import { useBrowserStm } from '~hooks/payments' import { useIsMobile } from '~hooks/useIsMobile' import Button from '~components/Button' import ButtonGroup from '~components/ButtonGroup' +import { ModalCloseButton } from '~components/Modal' import { getPaymentPageUrl } from '~features/public-form/utils/urls' @@ -61,7 +62,10 @@ export const PublicFormPaymentResumeModal = (): JSX.Element => { > - Restore previous session? + + + Restore previous session? + We noticed an incomplete session on this form. You can restore your previous session and complete payment. diff --git a/frontend/src/features/public-form/components/FormPaymentPage/components/VariablePaymentItemDetailsBlock.tsx b/frontend/src/features/public-form/components/FormPaymentPage/components/VariablePaymentItemDetailsBlock.tsx index d34252ff62..bd9300b014 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/components/VariablePaymentItemDetailsBlock.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/components/VariablePaymentItemDetailsBlock.tsx @@ -18,7 +18,7 @@ import { VariableItemDetailProps } from './types' export const VariablePaymentItemDetailsBlock = ({ paymentDescription, paymentItemName, - paymentMin, + paymentMin: _paymentMin, paymentMax: _paymentMax, }: VariableItemDetailProps): JSX.Element => { const { @@ -26,9 +26,14 @@ export const VariablePaymentItemDetailsBlock = ({ formState: { errors }, } = useFormContext() - const { data: { maxPaymentAmountCents = Number.MAX_SAFE_INTEGER } = {} } = - useEnv() + const { + data: { + maxPaymentAmountCents = Number.MAX_SAFE_INTEGER, + minPaymentAmountCents = Number.MAX_SAFE_INTEGER, + } = {}, + } = useEnv() const paymentMax = _paymentMax || maxPaymentAmountCents + const paymentMin = _paymentMin || minPaymentAmountCents const amountValidation = usePaymentFieldValidation< { [PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID]: string diff --git a/frontend/src/features/rollout-announcement/RolloutAnnouncementModal.tsx b/frontend/src/features/rollout-announcement/RolloutAnnouncementModal.tsx index d0e70fc42f..ed4673579c 100644 --- a/frontend/src/features/rollout-announcement/RolloutAnnouncementModal.tsx +++ b/frontend/src/features/rollout-announcement/RolloutAnnouncementModal.tsx @@ -4,7 +4,6 @@ import { useSwipeable } from 'react-swipeable' import { Flex, Modal, - ModalCloseButton, ModalContent, ModalFooter, ModalOverlay, @@ -13,6 +12,7 @@ import { import { useIsMobile } from '~hooks/useIsMobile' import Button from '~components/Button' +import { ModalCloseButton } from '~components/Modal' import { ProgressIndicator } from '../../components/ProgressIndicator/ProgressIndicator' diff --git a/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx index 198a70e5cb..16f2a32eac 100644 --- a/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx +++ b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx @@ -22,11 +22,7 @@ import { } from '@chakra-ui/react' import simplur from 'simplur' -import { - ChildrenCompoundFieldInputs, - ChildrenCompoundFieldSchema, - DATE_DISPLAY_FORMAT, -} from '~shared/constants/dates' +import { DATE_DISPLAY_FORMAT } from '~shared/constants/dates' import { MYINFO_ATTRIBUTE_MAP } from '~shared/constants/field/myinfo' import { FormColorTheme, @@ -45,6 +41,10 @@ import { FormLabel } from '~components/FormControl/FormLabel/FormLabel' import { IconButton } from '~components/IconButton/IconButton' import { BaseFieldProps, FieldContainer } from '../FieldContainer' +import { + ChildrenCompoundFieldInputs, + ChildrenCompoundFieldSchema, +} from '../types' export interface ChildrenCompoundFieldProps extends BaseFieldProps { schema: ChildrenCompoundFieldSchema @@ -94,7 +94,7 @@ export const ChildrenCompoundField = ({ // Initialize with a single child section useEffect(() => { if (!fields || !fields.length) { - append(['']) + append([''], { shouldFocus: false }) } }, [fields, append]) @@ -246,6 +246,14 @@ const ChildrenBody = ({ return allChildren.filter((name) => !temp.has(name)) }, [myInfoChildrenBirthRecords, allChildren, allSelectedNames]) + const childNameValues = useMemo(() => { + return [childName, ...namesNotSelected()].filter((name) => { + if (name === '' || name === undefined) { + return false + } else return true + }) + }, [childName, namesNotSelected]) + const indexOfChild: number = useMemo(() => { return ( myInfoChildrenBirthRecords?.[MyInfoChildAttributes.ChildName]?.indexOf( @@ -263,6 +271,16 @@ const ChildrenBody = ({ if (indexOfChild === undefined || indexOfChild < 0) { return '' } + + // We use the childname to check if the parent has a child above 21. + // If the childname is an empty string, it represents a child above 21. + // As our definition of child in FormSG means child below 21, we want to + // return empty strings for other child attributes even if their value is populated by myinfo + // if there is no childname. + if (myInfoChildrenBirthRecords.childname?.[indexOfChild] === '') { + return '' + } + const result = myInfoChildrenBirthRecords?.[attr]?.[indexOfChild] // Unknown basically means no result if ( @@ -292,10 +310,9 @@ const ChildrenBody = ({ {...selectRest} placeholder={"Select your child's name"} colorScheme={`theme-${colorTheme}`} - items={[childName, ...namesNotSelected()].filter((e) => e !== '')} + items={childNameValues} value={childName} isDisabled={isSubmitting} - initialIsOpen={!!myInfoChildrenBirthRecords?.childname} onChange={(name) => { // This is bad practice but we have no choice because our // custom Select doesn't forward the event. diff --git a/frontend/src/templates/NavigationPrompt/UnsavedChangesModal.tsx b/frontend/src/templates/NavigationPrompt/UnsavedChangesModal.tsx index 49e212da72..20acd059cd 100644 --- a/frontend/src/templates/NavigationPrompt/UnsavedChangesModal.tsx +++ b/frontend/src/templates/NavigationPrompt/UnsavedChangesModal.tsx @@ -2,16 +2,17 @@ import { Button, Modal, ModalBody, - ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, ModalProps, Stack, + useBreakpointValue, } from '@chakra-ui/react' import { useIsMobile } from '~hooks/useIsMobile' +import { ModalCloseButton } from '~components/Modal' export interface UnsavedChangesModalProps extends Omit { onConfirm: () => void @@ -41,6 +42,11 @@ export const UnsavedChangesModal = ({ cancelButtonText = 'No, stay on page', ...modalProps }: UnsavedChangesModalProps): JSX.Element => { + const modalSize = useBreakpointValue({ + base: 'mobile', + xs: 'mobile', + md: 'md', + }) const isMobile = useIsMobile() return ( @@ -48,6 +54,7 @@ export const UnsavedChangesModal = ({ isOpen={isOpen} onClose={onClose} returnFocusOnClose={returnFocusOnClose} + size={modalSize} {...modalProps} > diff --git a/init-localstack.sh b/init-localstack.sh old mode 100644 new mode 100755 index 7601903abb..86cb788e05 --- a/init-localstack.sh +++ b/init-localstack.sh @@ -29,7 +29,9 @@ awslocal s3 mb s3://$STATIC_ASSETS_S3_BUCKET # Buckets for virus scanner # Set to versioning enabled -awslocal s3 mb s3://$VIRUS_SCANNER_QUARANTINE_S3_BUCKET --versioning-configuration Status=Enabled -awslocal s3 mb s3://$VIRUS_SCANNER_CLEAN_S3_BUCKET --versioning-configuration Status=Enabled +awslocal s3 mb s3://$VIRUS_SCANNER_QUARANTINE_S3_BUCKET +awslocal s3api put-bucket-versioning --bucket $VIRUS_SCANNER_QUARANTINE_S3_BUCKET --versioning-configuration Status=Enabled +awslocal s3 mb s3://$VIRUS_SCANNER_CLEAN_S3_BUCKET +awslocal s3api put-bucket-versioning --bucket $VIRUS_SCANNER_CLEAN_S3_BUCKET --versioning-configuration Status=Enabled set +x diff --git a/package-lock.json b/package-lock.json index 8016a6d6fa..6ad17f40a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.77.0", + "version": "6.78.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.77.0", + "version": "6.78.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 2eb45d9e27..3b410dc96e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.77.0", + "version": "6.78.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " diff --git a/serverless/virus-scanner/src/__tests/s3.service.spec.ts b/serverless/virus-scanner/src/__tests/s3.service.spec.ts index 2f6a18c738..c8bbee5c7f 100644 --- a/serverless/virus-scanner/src/__tests/s3.service.spec.ts +++ b/serverless/virus-scanner/src/__tests/s3.service.spec.ts @@ -5,10 +5,11 @@ import { CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import * as LoggerService from '../logger' import { S3Service } from '../s3.service' +const VersionId = 'mockObjectVersionId' // Mock S3Client let getResult = { Body: 'mockBody', - VersionId: 'mockObjectVersionId', + VersionId, } jest.mock('@aws-sdk/client-s3', () => { return { @@ -20,7 +21,7 @@ jest.mock('@aws-sdk/client-s3', () => { } }), CopyObjectCommand: jest.fn().mockImplementation(() => { - return + return { VersionId } }), DeleteObjectCommand: jest.fn().mockImplementation(() => { return @@ -99,6 +100,7 @@ describe('S3Service', () => { sourceObjectVersionId: 'sourceObjectVersionId', destinationBucketName: 'destinationBucketName', destinationObjectKey: 'destinationObjectKey', + destinationVersionId: 'mockObjectVersionId', }, 'Moved document in s3', ) diff --git a/serverless/virus-scanner/src/index.ts b/serverless/virus-scanner/src/index.ts index d4ba752328..a31fc37280 100644 --- a/serverless/virus-scanner/src/index.ts +++ b/serverless/virus-scanner/src/index.ts @@ -147,8 +147,11 @@ export const handler = async ( key: quarantineFileKey, versionId, }) + + let destinationVersionId: string + try { - await s3Client.moveS3File({ + destinationVersionId = await s3Client.moveS3File({ sourceBucketName: quarantineBucket, sourceObjectKey: quarantineFileKey, sourceObjectVersionId: versionId, @@ -174,6 +177,7 @@ export const handler = async ( logger.info({ message: 'clean file moved to clean bucket', cleanFileKey, + destinationVersionId, }) return { @@ -181,6 +185,7 @@ export const handler = async ( body: JSON.stringify({ message: 'File scan completed', cleanFileKey, + destinationVersionId, }), } } diff --git a/serverless/virus-scanner/src/s3.service.ts b/serverless/virus-scanner/src/s3.service.ts index ab01dfa6f8..1dd6c78f0f 100644 --- a/serverless/virus-scanner/src/s3.service.ts +++ b/serverless/virus-scanner/src/s3.service.ts @@ -132,7 +132,7 @@ export class S3Service { sourceObjectVersionId, destinationBucketName, destinationObjectKey, - }: MoveS3FileParams) { + }: MoveS3FileParams): Promise { this.logger.info( { sourceBucketName, @@ -145,7 +145,7 @@ export class S3Service { ) try { - await this.s3Client.send( + const { VersionId } = await this.s3Client.send( new CopyObjectCommand({ Key: destinationObjectKey, Bucket: destinationBucketName, @@ -153,6 +153,21 @@ export class S3Service { }), ) + if (!VersionId) { + this.logger.error( + { + sourceBucketName, + sourceObjectKey, + sourceObjectVersionId, + destinationBucketName, + destinationObjectKey, + }, + 'VersionId is empty after copying object in s3', + ) + + throw new Error('VersionId is empty') + } + await this.s3Client.send( new DeleteObjectCommand({ Key: sourceObjectKey, @@ -168,9 +183,12 @@ export class S3Service { sourceObjectVersionId, destinationBucketName, destinationObjectKey, + destinationVersionId: VersionId, }, 'Moved document in s3', ) + + return VersionId } catch (error) { this.logger.error( { diff --git a/shared/constants/errors.ts b/shared/constants/errors.ts index cef0710f81..b786872599 100644 --- a/shared/constants/errors.ts +++ b/shared/constants/errors.ts @@ -2,3 +2,9 @@ export const ERROR_QUERY_PARAM_KEY = 'error_type' // Payment errors namespace (100xxx) export const DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT = '100001' + +// GoGov Bad Request error messages +export const GO_VALIDATION_ERROR_MESSAGE = + 'Validation error when claiming GoGov link' + +export const GO_ALREADY_EXIST_ERROR_MESSAGE = 'GoGov link already exists' diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index f73ca2c65e..f1c57d7bbc 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -6,4 +6,6 @@ export const featureFlags = { encryptionBoundaryShift: 'encryption-boundary-shift' as const, encryptionBoundaryShiftHardValidation: 'encryption-boundary-shift-hard-validation' as const, + encryptionBoundaryShiftVirusScanner: + 'encryption-boundary-shift-virus-scanner' as const, } diff --git a/shared/constants/field/myinfo/index.ts b/shared/constants/field/myinfo/index.ts index 72473222ca..fe8f80662d 100644 --- a/shared/constants/field/myinfo/index.ts +++ b/shared/constants/field/myinfo/index.ts @@ -5,11 +5,21 @@ import { MyInfoChildVaxxStatus, MyInfoField, } from '../../../types/field' -import COUNTRIES from './myinfo-countries' -import DIALECTS from './myinfo-dialects' -import NATIONALITIES from './myinfo-nationalities' -import OCCUPATIONS from './myinfo-occupations' -import RACES from './myinfo-races' +import { myInfoCountries } from './myinfo-countries' +import { myInfoDialects } from './myinfo-dialects' +import { myInfoNationalities } from './myinfo-nationalities' +import { myInfoOccupations } from './myinfo-occupations' +import { myInfoRaces } from './myinfo-races' +import { myInfoHousingTypes } from './myinfo-housing-types' +import { myInfoHdbTypes } from './myinfo-hdb-types' + +export * from './myinfo-countries' +export * from './myinfo-dialects' +export * from './myinfo-nationalities' +export * from './myinfo-occupations' +export * from './myinfo-races' +export * from './myinfo-hdb-types' +export * from './myinfo-housing-types' export type MyInfoVerifiedType = 'SG' | 'PR' | 'F' @@ -73,7 +83,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The race of the form-filler. This field is verified by ICA for Singaporean/PRs & foreigners on Long-Term Visit Pass, and by MOM for Employment Pass holders.', fieldType: BasicField.Dropdown, - fieldOptions: RACES, + fieldOptions: myInfoRaces, previewValue: 'CHINESE', }, { @@ -85,7 +95,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The nationality of the form-filler. This field is verified by ICA for Singaporeans/PRs & foreigners on Long-Term Visit Pass, and by MOM for Employment Pass holders.', fieldType: BasicField.Dropdown, - fieldOptions: NATIONALITIES, + fieldOptions: myInfoNationalities, previewValue: 'SINGAPORE CITIZEN', }, { @@ -97,7 +107,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The birth country of the form-filler. This field is verified by ICA for Singaporeans/PRs & foreigners on Long-Term Visit Pass, and by MOM for Employment Pass holders.', fieldType: BasicField.Dropdown, - fieldOptions: COUNTRIES, + fieldOptions: myInfoCountries, previewValue: 'SINGAPORE', }, { @@ -119,7 +129,7 @@ export const types: MyInfoFieldBlock[] = [ source: 'Immigration and Checkpoints Authority', description: 'The dialect group of the form-filler.', fieldType: BasicField.Dropdown, - fieldOptions: DIALECTS, + fieldOptions: myInfoDialects, previewValue: 'HOKKIEN', }, { @@ -131,14 +141,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The type of housing that the form-filler lives in. This information is verified by HDB for public housing, and by URA for private housing.', fieldType: BasicField.Dropdown, - fieldOptions: [ - 'APARTMENT', - 'CONDOMINIUM', - 'DETACHED HOUSE', - 'EXECUTIVE CONDOMINIUM', - 'SEMI-DETACHED HOUSE', - 'TERRACE HOUSE', - ], + fieldOptions: myInfoHousingTypes, previewValue: 'DETACHED HOUSE', }, { @@ -149,15 +152,7 @@ export const types: MyInfoFieldBlock[] = [ source: 'Housing Development Board', description: 'The type of HDB flat that the form-filler lives in.', fieldType: BasicField.Dropdown, - fieldOptions: [ - '1-ROOM FLAT (HDB)', - '2-ROOM FLAT (HDB)', - '3-ROOM FLAT (HDB)', - '4-ROOM FLAT (HDB)', - '5-ROOM FLAT (HDB)', - 'EXECUTIVE FLAT (HDB)', - 'STUDIO APARTMENT (HDB)', - ], + fieldOptions: myInfoHdbTypes, previewValue: 'EXECUTIVE FLAT (HDB)', }, { @@ -201,7 +196,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The country of marriage of the form-filler. This field is treated as unverified, as data provided by MSF may be outdated in cases of marriages in a foreign country.', fieldType: BasicField.Dropdown, - fieldOptions: COUNTRIES, + fieldOptions: myInfoCountries, previewValue: 'SINGAPORE', }, { @@ -223,7 +218,7 @@ export const types: MyInfoFieldBlock[] = [ description: 'The occupation of the form-filler. Verified for foreigners with Singpass only.', fieldType: BasicField.Dropdown, - fieldOptions: OCCUPATIONS, + fieldOptions: myInfoOccupations, previewValue: 'MANAGING DIRECTOR/CHIEF EXECUTIVE OFFICER', }, { @@ -318,7 +313,7 @@ export const types: MyInfoFieldBlock[] = [ verified: ['SG', 'PR', 'F'], source: 'Immigration & Checkpoints Authority / Health Promotion Board', description: - "The data of the form-filler's children. Vaccination status is verified by HPB. All other data in this field is verified by ICA.", + 'The data of the form-filler’s children. Only data of children below 21 years old will be available. Vaccination status is verified by HPB. All other data is verified by ICA.', fieldType: BasicField.Children, previewValue: 'Child 1', }, @@ -372,7 +367,7 @@ export const types: MyInfoFieldBlock[] = [ source: 'Immigration & Checkpoints Authority', description: 'Race', fieldType: BasicField.Dropdown, - fieldOptions: RACES, + fieldOptions: myInfoRaces, previewValue: 'CHINESE', }, { @@ -383,7 +378,7 @@ export const types: MyInfoFieldBlock[] = [ source: 'Immigration & Checkpoints Authority', description: 'Secondary race', fieldType: BasicField.Dropdown, - fieldOptions: RACES, + fieldOptions: myInfoRaces, previewValue: 'CHINESE', }, ] diff --git a/shared/constants/field/myinfo/myinfo-countries.ts b/shared/constants/field/myinfo/myinfo-countries.ts index 15954d6df9..cc2e5efec6 100644 --- a/shared/constants/field/myinfo/myinfo-countries.ts +++ b/shared/constants/field/myinfo/myinfo-countries.ts @@ -1,4 +1,4 @@ -const myInfoCountries = [ +export const myInfoCountries = [ 'AFGHANISTAN', 'ALBANIA', 'ALGERIA', @@ -254,5 +254,3 @@ const myInfoCountries = [ 'ZAMBIA', 'ZIMBABWE', ] - -export default myInfoCountries diff --git a/shared/constants/field/myinfo/myinfo-dialects.ts b/shared/constants/field/myinfo/myinfo-dialects.ts index a35e785cda..2be859c197 100644 --- a/shared/constants/field/myinfo/myinfo-dialects.ts +++ b/shared/constants/field/myinfo/myinfo-dialects.ts @@ -1,4 +1,4 @@ -const myInfoDialects = [ +export const myInfoDialects = [ 'AKAN', 'ALBANIAN', 'AMHARIC', @@ -146,5 +146,3 @@ const myInfoDialects = [ 'WENCHOW', 'YIDDISH', ] - -export default myInfoDialects diff --git a/shared/constants/field/myinfo/myinfo-hdb-types.ts b/shared/constants/field/myinfo/myinfo-hdb-types.ts new file mode 100644 index 0000000000..5ebbdf0461 --- /dev/null +++ b/shared/constants/field/myinfo/myinfo-hdb-types.ts @@ -0,0 +1,9 @@ +export const myInfoHdbTypes = [ + '1-ROOM FLAT (HDB)', + '2-ROOM FLAT (HDB)', + '3-ROOM FLAT (HDB)', + '4-ROOM FLAT (HDB)', + '5-ROOM FLAT (HDB)', + 'EXECUTIVE FLAT (HDB)', + 'STUDIO APARTMENT (HDB)', +] diff --git a/shared/constants/field/myinfo/myinfo-housing-types.ts b/shared/constants/field/myinfo/myinfo-housing-types.ts new file mode 100644 index 0000000000..3afe23b450 --- /dev/null +++ b/shared/constants/field/myinfo/myinfo-housing-types.ts @@ -0,0 +1,8 @@ +export const myInfoHousingTypes = [ + 'APARTMENT', + 'CONDOMINIUM', + 'DETACHED HOUSE', + 'EXECUTIVE CONDOMINIUM', + 'SEMI-DETACHED HOUSE', + 'TERRACE HOUSE', +] diff --git a/shared/constants/field/myinfo/myinfo-nationalities.ts b/shared/constants/field/myinfo/myinfo-nationalities.ts index 441d40999a..24e53945a5 100644 --- a/shared/constants/field/myinfo/myinfo-nationalities.ts +++ b/shared/constants/field/myinfo/myinfo-nationalities.ts @@ -1,4 +1,4 @@ -const myInfoNationalities = [ +export const myInfoNationalities = [ 'AFGHAN', 'ALBANIAN', 'ALGERIAN', @@ -206,5 +206,3 @@ const myInfoNationalities = [ 'ZAMBIAN', 'ZIMBABWEAN', ] - -export default myInfoNationalities diff --git a/shared/constants/field/myinfo/myinfo-occupations.ts b/shared/constants/field/myinfo/myinfo-occupations.ts index 58f9e963f6..c93802234e 100644 --- a/shared/constants/field/myinfo/myinfo-occupations.ts +++ b/shared/constants/field/myinfo/myinfo-occupations.ts @@ -8336,5 +8336,3 @@ export const myInfoOccupations = [ 'YOUTH RESIDENTIAL ASSISTANT', 'YOUTH WORKER', ] - -export default myInfoOccupations diff --git a/shared/constants/field/myinfo/myinfo-races.ts b/shared/constants/field/myinfo/myinfo-races.ts index dc814ecdde..26fef01f77 100644 --- a/shared/constants/field/myinfo/myinfo-races.ts +++ b/shared/constants/field/myinfo/myinfo-races.ts @@ -1,4 +1,4 @@ -const myInfoRaces = [ +export const myInfoRaces = [ 'ACHEHNESE', 'AFGHAN', 'AFRICAN', @@ -226,5 +226,3 @@ const myInfoRaces = [ 'YUGOSLAV', 'ZIMBABWEAN', ] - -export default myInfoRaces diff --git a/src/app/modules/feature-flags/feature-flags.service.ts b/src/app/modules/feature-flags/feature-flags.service.ts index 33d906d87a..e893a17652 100644 --- a/src/app/modules/feature-flags/feature-flags.service.ts +++ b/src/app/modules/feature-flags/feature-flags.service.ts @@ -46,7 +46,7 @@ export const getEnabledFlags = (): ResultAsync => { * @returns boolean that represents the status of the feature flag or the fallback value */ export const getFeatureFlag = ( - flag: keyof typeof featureFlags, + flag: typeof featureFlags[keyof typeof featureFlags], options?: { fallbackValue?: boolean logMeta?: CustomLoggerParams['meta'] diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index b823057a56..af4982ea95 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -44,6 +44,7 @@ import { } from 'src/app/services/mail/mail.errors' import MailService from 'src/app/services/mail/mail.service' import { TwilioCredentials } from 'src/app/services/sms/sms.types' +import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { EditFieldActions } from 'src/shared/constants' import { FormFieldSchema, @@ -91,7 +92,6 @@ import { import * as FormService from '../../form.service' import * as AdminFormController from '../admin-form.controller' import { - CreatePresignedUrlError, EditFieldError, FieldNotFoundError, InvalidFileTypeError, @@ -1225,7 +1225,7 @@ describe('admin-form.controller', () => { }) }) - it('should return 400 when CreatePresignedUrlError is returned when creating presigned POST URL', async () => { + it('should return 400 when CreatePresignedPostError is returned when creating presigned POST URL', async () => { // Arrange // Mock various services to return expected results. MockUserService.getPopulatedUserById.mockReturnValueOnce( @@ -1238,7 +1238,7 @@ describe('admin-form.controller', () => { const mockErrorString = 'creating presigned post url failed, oh no' const mockRes = expressHandler.mockResponse() MockAdminFormService.createPresignedPostUrlForImages.mockReturnValueOnce( - errAsync(new CreatePresignedUrlError(mockErrorString)), + errAsync(new CreatePresignedPostError(mockErrorString)), ) // Act @@ -1460,7 +1460,7 @@ describe('admin-form.controller', () => { }) }) - it('should return 400 when CreatePresignedUrlError is returned when creating presigned POST URL', async () => { + it('should return 400 when CreatePresignedPostError is returned when creating presigned POST URL', async () => { // Arrange const mockRes = expressHandler.mockResponse() // Mock error @@ -1473,7 +1473,7 @@ describe('admin-form.controller', () => { okAsync(MOCK_FORM), ) MockAdminFormService.createPresignedPostUrlForLogos.mockReturnValueOnce( - errAsync(new CreatePresignedUrlError(mockErrorString)), + errAsync(new CreatePresignedPostError(mockErrorString)), ) // Act diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index 6fb511e332..09d3a2142f 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -23,6 +23,7 @@ import { MissingUserError } from 'src/app/modules/user/user.errors' import * as UserService from 'src/app/modules/user/user.service' import { SmsLimitExceededError } from 'src/app/modules/verification/verification.errors' import { TwilioCredentials } from 'src/app/services/sms/sms.types' +import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error' import { EditFieldActions } from 'src/shared/constants' import { @@ -64,7 +65,6 @@ import { TransferOwnershipError, } from '../../form.errors' import { - CreatePresignedUrlError, EditFieldError, FieldNotFoundError, InvalidCollaboratorError, @@ -231,7 +231,7 @@ describe('admin-form.service', () => { ) }) - it('should return CreatePresignedUrlError when error occurs whilst creating presigned POST URL', async () => { + it('should return CreatePresignedPostError when error occurs whilst creating presigned POST URL', async () => { // Arrange // Mock external service failure. const s3Spy = jest @@ -258,7 +258,7 @@ describe('admin-form.service', () => { ) expect(actualResult.isErr()).toEqual(true) expect(actualResult._unsafeUnwrapErr()).toEqual( - new CreatePresignedUrlError('Error occurred whilst uploading file'), + new CreatePresignedPostError('Error occurred whilst uploading file'), ) }) }) @@ -324,7 +324,7 @@ describe('admin-form.service', () => { ) }) - it('should return CreatePresignedUrlError when error occurs whilst creating presigned POST URL', async () => { + it('should return CreatePresignedPostError when error occurs whilst creating presigned POST URL', async () => { // Arrange // Mock external service failure. const s3Spy = jest @@ -351,7 +351,7 @@ describe('admin-form.service', () => { ) expect(actualResult.isErr()).toEqual(true) expect(actualResult._unsafeUnwrapErr()).toEqual( - new CreatePresignedUrlError('Error occurred whilst uploading file'), + new CreatePresignedPostError('Error occurred whilst uploading file'), ) }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 4597e6e137..0fbbef2d59 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -93,7 +93,7 @@ import { PREVIEW_CORPPASS_UINFIN, PREVIEW_SINGPASS_UINFIN, } from './admin-form.constants' -import { EditFieldError, GoGovError } from './admin-form.errors' +import { EditFieldError, GoGovServerError } from './admin-form.errors' import { getWebhookSettingsValidator, updateSettingsValidator, @@ -101,7 +101,11 @@ import { } from './admin-form.middlewares' import * as AdminFormService from './admin-form.service' import { PermissionLevel } from './admin-form.types' -import { mapRouteError, verifyValidUnicodeString } from './admin-form.utils' +import { + mapGoGovErrors, + mapRouteError, + verifyValidUnicodeString, +} from './admin-form.utils' // NOTE: Refer to this for documentation: https://github.com/sideway/joi-date/blob/master/API.md const Joi = BaseJoi.extend(JoiDate) as typeof BaseJoi @@ -2989,13 +2993,18 @@ export const handleSetGoLinkSuffix: ControllerHandler< }, }, ), - // TODO: fix error handling (https://linear.app/ogp/issue/FRM-901/improve-error-handling-when-calling-gogov-api) - () => new GoGovError(), + (error) => { + if (axios.isAxiosError(error)) { + return mapGoGovErrors(error) + } + + return new GoGovServerError() + }, ) }) // Step 3: After obtaining GoGov link, save it to the form .andThen(() => AdminFormService.setGoLinkSuffix(formId, linkSuffix)) - .map((data) => res.status(StatusCodes.OK).json(data)) + .map(() => res.sendStatus(StatusCodes.OK)) .mapErr((error) => { logger.error({ message: 'Error occurred when setting GoGov link suffix', diff --git a/src/app/modules/form/admin-form/admin-form.errors.ts b/src/app/modules/form/admin-form/admin-form.errors.ts index 1bc43c92d8..1cefcd45b2 100644 --- a/src/app/modules/form/admin-form/admin-form.errors.ts +++ b/src/app/modules/form/admin-form/admin-form.errors.ts @@ -1,3 +1,7 @@ +import { + GO_ALREADY_EXIST_ERROR_MESSAGE, + GO_VALIDATION_ERROR_MESSAGE, +} from '../../../../../shared/constants' import { ApplicationError } from '../../core/core.errors' export class InvalidFileTypeError extends ApplicationError { @@ -6,12 +10,6 @@ export class InvalidFileTypeError extends ApplicationError { } } -export class CreatePresignedUrlError extends ApplicationError { - constructor(message: string) { - super(message) - } -} - export class EditFieldError extends ApplicationError { constructor(message: string) { super(message) @@ -38,8 +36,40 @@ export class PaymentChannelNotFoundError extends ApplicationError { } } +// Family of GoGov Errors from GoGov Integration export class GoGovError extends ApplicationError { constructor(message = 'Error occurred when claiming GoGov link') { super(message) } } + +export class GoGovValidationError extends GoGovError { + constructor(message = GO_VALIDATION_ERROR_MESSAGE) { + super(message) + } +} + +export class GoGovAlreadyExistError extends GoGovError { + constructor(message = GO_ALREADY_EXIST_ERROR_MESSAGE) { + super(message) + } +} + +export class GoGovRequestLimitError extends GoGovError { + constructor(message = 'GoGov request limit exceeded') { + super(message) + } +} + +export class GoGovBadGatewayError extends GoGovError { + constructor(message = 'GoGov request failed') { + super(message) + } +} + +export class GoGovServerError extends GoGovError { + // Default error message will be shown if not AxiosError + constructor(message = 'Unexpected error occured when claiming GoGov link') { + super(message) + } +} diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 037c9f904b..c27a1df177 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -48,6 +48,10 @@ import getAgencyModel from '../../../models/agency.server.model' import getFormModel from '../../../models/form.server.model' import * as SmsService from '../../../services/sms/sms.service' import { twilioClientCache } from '../../../services/sms/sms.service' +import { + createPresignedPostDataPromise, + CreatePresignedPostError, +} from '../../../utils/aws-s3' import { dotifyObject } from '../../../utils/dotify-object' import { isVerifiableMobileField } from '../../../utils/field-validation/field-validation.guards' import { @@ -87,7 +91,6 @@ import { } from './../../../services/sms/sms.types' import { PRESIGNED_POST_EXPIRY_SECS } from './admin-form.constants' import { - CreatePresignedUrlError, EditFieldError, FieldNotFoundError, InvalidCollaboratorError, @@ -167,7 +170,7 @@ const createPresignedPostUrl = ( { fileId, fileMd5Hash, fileType }: PresignedPostUrlParams, ): ResultAsync< PresignedPost, - InvalidFileTypeError | CreatePresignedUrlError + InvalidFileTypeError | CreatePresignedPostError > => { if (!VALID_UPLOAD_FILE_TYPES.includes(fileType)) { return errAsync( @@ -175,34 +178,17 @@ const createPresignedPostUrl = ( ) } - const presignedPostUrlPromise = new Promise( - (resolve, reject) => { - AwsConfig.s3.createPresignedPost( - { - Bucket: bucketName, - Expires: PRESIGNED_POST_EXPIRY_SECS, - Conditions: [ - // Content length restrictions: 0 to MAX_UPLOAD_FILE_SIZE. - ['content-length-range', 0, MAX_UPLOAD_FILE_SIZE], - ], - Fields: { - acl: 'public-read', - key: fileId, - 'Content-MD5': fileMd5Hash, - 'Content-Type': fileType, - }, - }, - (err, data) => { - if (err) { - return reject(err) - } - return resolve(data) - }, - ) - }, - ) + const presignedPostUrlPromise = createPresignedPostDataPromise({ + bucketName, + expiresSeconds: PRESIGNED_POST_EXPIRY_SECS, + size: MAX_UPLOAD_FILE_SIZE, + key: fileId, + acl: 'public-read', + fileMd5Hash, + fileType, + }) - return ResultAsync.fromPromise(presignedPostUrlPromise, (error) => { + return presignedPostUrlPromise.mapErr((error) => { logger.error({ message: 'Error encountered when creating presigned POST URL', meta: { @@ -214,7 +200,7 @@ const createPresignedPostUrl = ( error, }) - return new CreatePresignedUrlError('Error occurred whilst uploading file') + return new CreatePresignedPostError('Error occurred whilst uploading file') }) } @@ -227,13 +213,13 @@ const createPresignedPostUrl = ( * * @returns ok(presigned post url) when creation is successful * @returns err(InvalidFileTypeError) when given file type is not supported - * @returns err(CreatePresignedUrlError) when errors occurs on S3 side whilst creating presigned post url. + * @returns err(CreatePresignedPostError) when errors occurs on S3 side whilst creating presigned post url. */ export const createPresignedPostUrlForImages = ( uploadParams: PresignedPostUrlParams, ): ResultAsync< PresignedPost, - InvalidFileTypeError | CreatePresignedUrlError + InvalidFileTypeError | CreatePresignedPostError > => { return createPresignedPostUrl(AwsConfig.imageS3Bucket, uploadParams) } @@ -247,13 +233,13 @@ export const createPresignedPostUrlForImages = ( * * @returns ok(presigned post url) when creation is successful * @returns err(InvalidFileTypeError) when given file type is not supported - * @returns err(CreatePresignedUrlError) when errors occurs on S3 side whilst creating presigned post url. + * @returns err(CreatePresignedPostError) when errors occurs on S3 side whilst creating presigned post url. */ export const createPresignedPostUrlForLogos = ( uploadParams: PresignedPostUrlParams, ): ResultAsync< PresignedPost, - InvalidFileTypeError | CreatePresignedUrlError + InvalidFileTypeError | CreatePresignedPostError > => { return createPresignedPostUrl(AwsConfig.logoS3Bucket, uploadParams) } diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts index 675e70dd0c..4f0abbd9e5 100644 --- a/src/app/modules/form/admin-form/admin-form.utils.ts +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -1,3 +1,4 @@ +import { AxiosError } from 'axios' import { StatusCodes } from 'http-status-codes' import { err, ok, Result } from 'neverthrow' import { v4 as uuidv4 } from 'uuid' @@ -16,6 +17,7 @@ import { FormFieldSchema, IPopulatedForm, IUserSchema } from '../../../../types' import { EditFormFieldParams } from '../../../../types/api' import config from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' +import { CreatePresignedPostError } from '../../../utils/aws-s3' import { isPossibleEmailFieldSchema } from '../../../utils/field-validation/field-validation.guards' import { ApplicationError, @@ -46,10 +48,14 @@ import { import { UNICODE_ESCAPED_REGEX } from '../form.utils' import { - CreatePresignedUrlError, EditFieldError, FieldNotFoundError, + GoGovAlreadyExistError, + GoGovBadGatewayError, GoGovError, + GoGovRequestLimitError, + GoGovServerError, + GoGovValidationError, InvalidCollaboratorError, InvalidFileTypeError, PaymentChannelNotFoundError, @@ -80,7 +86,7 @@ export const mapRouteError = ( errorMessage: error.message, } case InvalidFileTypeError: - case CreatePresignedUrlError: + case CreatePresignedPostError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, @@ -176,11 +182,35 @@ export const mapRouteError = ( statusCode: StatusCodes.FORBIDDEN, errorMessage: error.message, } - case GoGovError: + case GoGovAlreadyExistError: + case GoGovValidationError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, } + case GoGovRequestLimitError: + return { + statusCode: StatusCodes.TOO_MANY_REQUESTS, + errorMessage: error.message, + } + case GoGovBadGatewayError: + return { + statusCode: StatusCodes.BAD_GATEWAY, + errorMessage: error.message, + } + case GoGovError: + case GoGovServerError: + logger.error({ + message: 'GoGov server error observed', + meta: { + action: 'mapRouteError', + }, + error, + }) + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: error.message, + } default: logger.error({ message: 'Unknown route error observed', @@ -517,3 +547,41 @@ export const verifyUserBetaflag = ( ), ) } + +// Utility method to map an axios error to a GoGovError +export const mapGoGovErrors = (error: AxiosError): GoGovError => { + type GoGovReturnedData = { message: string; type?: string } + // Hard coded error message from GoGov for URL Bad Request + // TODO: Verify with GoGov team if error response data can be different from general short link error + const urlFormatError = 'Only HTTPS URLs are allowed' + + const responseData = error.response?.data as GoGovReturnedData + + switch (error.response?.status) { + case StatusCodes.BAD_REQUEST: + // There can be three types of Bad Request from GoGov + // Short link already exists, which returns type=ShortUrlError + // Or validation error, which does not contain type + // Or if url is not https (like localhost), however, this should not happen as we prepend the app url in admin-form-controller + // TODO: Update the conditional when GoGov upgrades their return type shape + return responseData.type + ? new GoGovAlreadyExistError() + : !responseData.message.includes(urlFormatError) + ? new GoGovValidationError() + : new GoGovServerError( + 'GoGov server returned 400 for URL formatting error', + ) + case StatusCodes.TOO_MANY_REQUESTS: + return new GoGovRequestLimitError() + // For gogov API this is equivalent to Request Failed + case StatusCodes.PAYMENT_REQUIRED: + return new GoGovBadGatewayError() + // All other cases will default to 500 error + default: + return new GoGovServerError( + `GoGov server returned ${error.response?.status} error code with ${ + (error.response?.data as GoGovReturnedData).message + } message`, + ) + } +} diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 3b0aefd028..3a8f0f8047 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -60,10 +60,12 @@ import { compareHashedValues, createRelayState, getMyInfoAttr, + getMyInfoAttributeConstantsList, hashFieldValues, isMyInfoChildrenBirthRecords, isMyInfoLoginCookie, isMyInfoRelayState, + logIfFieldValueNotInMyinfoList, validateMyInfoForm, } from './myinfo.util' import getMyInfoHashModel from './myinfo_hash.model' @@ -270,6 +272,19 @@ export class MyInfoServiceClass { const { fieldValue, isReadOnly } = myInfoData.getFieldValueForAttr( myInfoAttr as InternalAttr, ) + + // Check if field value exists in our constants lists. If it doesn't, log the error + if (fieldValue) { + const myInfoConstantsList = getMyInfoAttributeConstantsList(myInfoAttr) + if (myInfoConstantsList) { + logIfFieldValueNotInMyinfoList( + fieldValue, + myInfoAttr, + myInfoConstantsList, + ) + } + } + const prefilledField = cloneDeep(field) as PossiblyPrefilledField prefilledField.fieldValue = fieldValue // Disable field diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 0d39c7115a..93c53d73d2 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -6,7 +6,16 @@ import mongoose, { LeanDocument } from 'mongoose' import { err, ok, Result } from 'neverthrow' import { v4 as uuidv4, validate as validateUUID } from 'uuid' -import { types as myInfoTypes } from '../../../../shared/constants/field/myinfo' +import { + myInfoCountries, + myInfoDialects, + myInfoHdbTypes, + myInfoHousingTypes, + myInfoNationalities, + myInfoOccupations, + myInfoRaces, + types as myInfoTypes, +} from '../../../../shared/constants/field/myinfo' import { BasicField, ChildrenCompoundFieldBase, @@ -526,3 +535,59 @@ export const handleMyInfoChildHashResponse = ( }) return } + +/** + * This function is responsible for mapping a myInfo attribute to + * an existing myInfo constants list + * + * @param myInfoAttr the myInfo attribute + */ +export const getMyInfoAttributeConstantsList = ( + myInfoAttr: string | string[], +) => { + switch (myInfoAttr) { + case MyInfoAttribute.Occupation: + return myInfoOccupations + case MyInfoAttribute.Race: + case MyInfoAttribute.ChildRace: + case MyInfoAttribute.ChildSecondaryRace: + return myInfoRaces + case MyInfoAttribute.Nationality: + return myInfoNationalities + case MyInfoAttribute.Dialect: + return myInfoDialects + case MyInfoAttribute.BirthCountry: + return myInfoCountries + case MyInfoAttribute.HousingType: + return myInfoHousingTypes + case MyInfoAttribute.HdbType: + return myInfoHdbTypes + default: + return + } +} + +/** + * Add logging to check if myInfo field value exists in a myInfo constants list + * @param fieldValue + * @param myInfoAttr + * @param myInfoList + */ + +export const logIfFieldValueNotInMyinfoList = ( + fieldValue: string, + myInfoAttr: string | string[], + myInfoList: string[], +) => { + const isFieldValueInMyinfoList = myInfoList.includes(fieldValue) + if (!isFieldValueInMyinfoList) { + logger.error({ + message: 'Myinfo field value not found in existing Myinfo constants list', + meta: { + action: 'prefillAndSaveMyInfoFields', + myInfoFieldValue: fieldValue, + myInfoAttr, + }, + }) + } +} diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 350f2cabd0..90552c148f 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -269,10 +269,11 @@ export const performPaymentPostSubmissionActions = ( formId: form._id, submissionId, email: payment.email, + paymentAmount: payment.amount, })) ) }) - .andThen(({ formTitle, formId, submissionId, email }) => { + .andThen(({ formTitle, formId, submissionId, email, paymentAmount }) => { logger.info({ message: 'Sending payment confirmation email', meta: { ...logMeta, submissionId, email }, @@ -284,6 +285,7 @@ export const performPaymentPostSubmissionActions = ( submissionId, formId, paymentId, + paymentAmount, }) .andThen(() => okAsync(undefined)) .orElse(() => { diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts index 25a87e767e..e1d21cca4e 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -1,10 +1,11 @@ import expressHandler from '__tests__/unit/backend/helpers/jest-express' import { ObjectId } from 'bson-ext' +import { StatusCodes } from 'http-status-codes' import { err, errAsync, ok, okAsync } from 'neverthrow' import * as AuthService from 'src/app/modules/auth/auth.service' import { DatabaseError } from 'src/app/modules/core/core.errors' -import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-form.errors' +import * as FeatureFlagService from 'src/app/modules/feature-flags/feature-flags.service' import { PermissionLevel } from 'src/app/modules/form/admin-form/admin-form.types' import { ForbiddenFormError, @@ -14,6 +15,7 @@ import { import { PaymentNotFoundError } from 'src/app/modules/payments/payments.errors' import { MissingUserError } from 'src/app/modules/user/user.errors' import * as UserService from 'src/app/modules/user/user.service' +import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { IPopulatedEncryptedForm, IPopulatedForm, @@ -33,17 +35,26 @@ import { } from '../../submission.errors' import { getMetadata, + getS3PresignedPostData, handleGetEncryptedResponse, streamEncryptedResponses, } from '../encrypt-submission.controller' import * as EncryptSubmissionService from '../encrypt-submission.service' +import { + AttachmentPresignedPostDataMapType, + AttachmentSizeMapType, +} from '../encrypt-submission.types' -jest.mock('../encrypt-submission.service') +jest.mock( + 'src/app/modules/submission/encrypt-submission/encrypt-submission.service', +) jest.mock('src/app/modules/user/user.service') jest.mock('src/app/modules/auth/auth.service') +jest.mock('src/app/modules/feature-flags/feature-flags.service') const MockEncryptSubService = jest.mocked(EncryptSubmissionService) const MockUserService = jest.mocked(UserService) const MockAuthService = jest.mocked(AuthService) +const MockFeatureFlagService = jest.mocked(FeatureFlagService) describe('encrypt-submission.controller', () => { beforeEach(() => jest.clearAllMocks()) @@ -357,7 +368,7 @@ describe('encrypt-submission.controller', () => { okAsync({} as SubmissionData), ) MockEncryptSubService.transformAttachmentMetasToSignedUrls.mockReturnValueOnce( - errAsync(new CreatePresignedUrlError(mockErrorString)), + errAsync(new CreatePresignedPostError(mockErrorString)), ) const mockRes = expressHandler.mockResponse() @@ -825,6 +836,90 @@ describe('encrypt-submission.controller', () => { }) }) + describe('getS3PresignedPostData', () => { + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + body: [ + { id: new ObjectId().toHexString(), size: 500 }, + ] as unknown as AttachmentSizeMapType[], + }) + + it('should return 500 if getFeatureFlag returns errAsync(DatabaseError)', async () => { + // Arrange + MockFeatureFlagService.getFeatureFlag.mockReturnValueOnce( + errAsync(new DatabaseError()), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await getS3PresignedPostData(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith( + StatusCodes.INTERNAL_SERVER_ERROR, + ) + }) + + it('should return 400 if getFeatureFlag returns okAsync(false)', async () => { + // Arrange + MockFeatureFlagService.getFeatureFlag.mockReturnValueOnce(okAsync(false)) + const mockRes = expressHandler.mockResponse() + + // Act + await getS3PresignedPostData(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) + }) + + it('should return 500 if getFeatureFlag returns okAsync(true) but getQuarantinePresignedPostData returns errAsync(CreatePresignedPostError)', async () => { + // Arrange + MockFeatureFlagService.getFeatureFlag.mockReturnValueOnce(okAsync(true)) + MockEncryptSubService.getQuarantinePresignedPostData.mockReturnValueOnce( + errAsync(new CreatePresignedPostError()), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await getS3PresignedPostData(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith( + StatusCodes.INTERNAL_SERVER_ERROR, + ) + }) + + it('should return 200 if getFeatureFlag returns okAsync(true) and getQuarantinePresignedPostData returns okAsync with the presigned URLs', async () => { + // Arrange + MockFeatureFlagService.getFeatureFlag.mockReturnValueOnce(okAsync(true)) + const MOCK_PRESIGNED_URLS = [ + { key: 'value' }, + ] as unknown as AttachmentPresignedPostDataMapType[] + + MockEncryptSubService.getQuarantinePresignedPostData.mockReturnValueOnce( + okAsync(MOCK_PRESIGNED_URLS), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await getS3PresignedPostData(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.OK) + expect(mockRes.send).toHaveBeenCalledWith(MOCK_PRESIGNED_URLS) + }) + }) + describe('streamEncryptedResponses', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts index 9a4766a1f0..589fcc1a36 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts @@ -12,8 +12,8 @@ import { DatabaseError, MalformedParametersError, } from 'src/app/modules/core/core.errors' -import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-form.errors' import { PaymentNotFoundError } from 'src/app/modules/payments/payments.errors' +import { CreatePresignedPostError } from 'src/app/utils/aws-s3' import { formatErrorRecoveryMessage } from 'src/app/utils/handle-mongo-error' import { IPaymentSchema, @@ -26,12 +26,18 @@ import { StorageModeSubmissionMetadata, SubmissionId, } from '../../../../../../shared/types' +import { aws as AwsConfig } from '../../../../config/config' import * as PaymentsService from '../../../payments/payments.service' import { SubmissionNotFoundError } from '../../submission.errors' +import { + AttachmentSizeLimitExceededError, + InvalidFieldIdError, +} from '../encrypt-submission.errors' import { addPaymentDataStream, createEncryptSubmissionWithoutSave, getEncryptedSubmissionData, + getQuarantinePresignedPostData, getSubmissionCursor, getSubmissionMetadata, getSubmissionMetadataList, @@ -833,7 +839,7 @@ describe('encrypt-submission.service', () => { expect(awsSpy).not.toHaveBeenCalled() }) - it('should return CreatePresignedUrlError when error occurs during the signed url creation process', async () => { + it('should return CreatePresignedPostError when error occurs during the signed url creation process', async () => { // Arrange jest .spyOn(aws.s3, 'getSignedUrlPromise') @@ -850,7 +856,7 @@ describe('encrypt-submission.service', () => { expect(actualResult.isErr()).toEqual(true) // Should reject even if there are some passing promises. expect(actualResult._unsafeUnwrapErr()).toEqual( - new CreatePresignedUrlError('Failed to create attachment URL'), + new CreatePresignedPostError('Failed to create attachment URL'), ) }) }) @@ -865,6 +871,12 @@ describe('encrypt-submission.service', () => { number: 200, refNo: mockSubmissionId as SubmissionId, submissionTime: 'some submission time', + payments: { + paymentAmt: 0, + email: '', + payoutDate: null, + transactionFee: null, + }, } const getMetaSpy = jest .spyOn(EncryptSubmission, 'findSingleMetadata') @@ -876,7 +888,7 @@ describe('encrypt-submission.service', () => { mockSubmissionId, ) - // Arrange + // Assert expect(actualResult.isOk()).toEqual(true) expect(actualResult._unsafeUnwrap()).toEqual(expectedMetadata) expect(getMetaSpy).toHaveBeenCalledWith(MOCK_FORM_ID, mockSubmissionId) @@ -892,7 +904,7 @@ describe('encrypt-submission.service', () => { invalidSubmissionId, ) - // Arrange + // Assert expect(actualResult.isOk()).toEqual(true) expect(actualResult._unsafeUnwrap()).toEqual(null) }) @@ -910,7 +922,7 @@ describe('encrypt-submission.service', () => { mockSubmissionId, ) - // Arrange + // Assert expect(actualResult.isOk()).toEqual(true) expect(actualResult._unsafeUnwrap()).toEqual(null) expect(getMetaSpy).toHaveBeenCalledWith(MOCK_FORM_ID, mockSubmissionId) @@ -930,7 +942,7 @@ describe('encrypt-submission.service', () => { mockSubmissionId, ) - // Arrange + // Assert expect(actualResult.isErr()).toEqual(true) expect(actualResult._unsafeUnwrapErr()).toEqual( new DatabaseError(formatErrorRecoveryMessage(mockErrorString)), @@ -961,7 +973,7 @@ describe('encrypt-submission.service', () => { // Act const actualResult = await getSubmissionMetadataList(MOCK_FORM_ID) - // Arrange + // Assert expect(actualResult.isOk()).toEqual(true) expect(actualResult._unsafeUnwrap()).toEqual(expectedResult) expect(getMetaSpy).toHaveBeenCalledWith(MOCK_FORM_ID, { page: undefined }) @@ -990,7 +1002,7 @@ describe('encrypt-submission.service', () => { mockPageNumber, ) - // Arrange + // Assert expect(actualResult.isOk()).toEqual(true) expect(actualResult._unsafeUnwrap()).toEqual(expectedResult) expect(getMetaSpy).toHaveBeenCalledWith(MOCK_FORM_ID, { @@ -1008,7 +1020,7 @@ describe('encrypt-submission.service', () => { // Act const actualResult = await getSubmissionMetadataList(MOCK_FORM_ID) - // Arrange + // Assert expect(actualResult.isErr()).toEqual(true) expect(actualResult._unsafeUnwrapErr()).toEqual( new DatabaseError(formatErrorRecoveryMessage(mockErrorString)), @@ -1016,6 +1028,127 @@ describe('encrypt-submission.service', () => { expect(getMetaSpy).toHaveBeenCalledWith(MOCK_FORM_ID, { page: undefined }) }) }) + + describe('getQuarantinePresignedPostData', () => { + const fieldId1 = new mongoose.Types.ObjectId() + const fieldId2 = new mongoose.Types.ObjectId() + const MOCK_ATTACHMENT_SIZES = [ + { id: fieldId1, size: 1 }, + { id: fieldId2, size: 2 }, + ] + + const REGEX_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + it('should return presigned post data', async () => { + // Arrange + const awsSpy = jest.spyOn(aws.s3, 'createPresignedPost') + const expectedCalledWithSubset = { + Bucket: AwsConfig.virusScannerQuarantineS3Bucket, + Fields: { key: expect.stringMatching(REGEX_UUID) }, + Expires: 1 * 60, // expires in 1 minutes + } + const expectedPresignedPostData = expect.objectContaining({ + url: `${AwsConfig.endPoint}/${AwsConfig.virusScannerQuarantineS3Bucket}`, + fields: expect.objectContaining({ + key: expect.stringMatching(REGEX_UUID), + bucket: AwsConfig.virusScannerQuarantineS3Bucket, + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + }), + }) + + // Act + const actualResult = await getQuarantinePresignedPostData( + MOCK_ATTACHMENT_SIZES, + ) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(awsSpy).toHaveBeenCalledTimes(2) + expect(awsSpy.mock.calls).toEqual([ + [ + { + ...expectedCalledWithSubset, + Conditions: [['content-length-range', 0, 1]], + }, + expect.any(Function), // anonymous error handling function + ], + [ + { + ...expectedCalledWithSubset, + Conditions: [['content-length-range', 0, 2]], + }, + expect.any(Function), // anonymous error handling function + ], + ]) + const actualResultValue = actualResult._unsafeUnwrap() + expect(actualResultValue).toEqual( + expect.objectContaining([ + { id: fieldId1, presignedPostData: expectedPresignedPostData }, + { id: fieldId2, presignedPostData: expectedPresignedPostData }, + ]), + ) + }) + + it('should return CreatePresignedPostError when aws.s3.createPresignedPost throws error', async () => { + // Arrange + const awsSpy = jest + .spyOn(aws.s3, 'createPresignedPost') + .mockImplementationOnce(() => { + throw new Error('some error') + }) + + // Act + const actualResult = await getQuarantinePresignedPostData( + MOCK_ATTACHMENT_SIZES, + ) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(awsSpy).toHaveBeenCalled() + expect(actualResult._unsafeUnwrapErr()).toEqual( + new CreatePresignedPostError(), + ) + expect(awsSpy).toHaveBeenCalledWith( + { + Bucket: AwsConfig.virusScannerQuarantineS3Bucket, + Fields: { key: expect.stringMatching(REGEX_UUID) }, + Expires: 1 * 60, // expires in 1 minutes + Conditions: [['content-length-range', 0, 1]], + }, + expect.any(Function), // anonymous error handling function + ) + }) + + it('should return InvalidFieldIdError when ids are not valid mongodb object ids', async () => { + // Arrange + const awsSpy = jest.spyOn(aws.s3, 'createPresignedPost') + + // Act + const actualResult = await getQuarantinePresignedPostData([ + { id: 'test_file_1' as unknown as ObjectId, size: 1 }, + ]) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(awsSpy).not.toHaveBeenCalled() + expect(actualResult._unsafeUnwrapErr()).toEqual(new InvalidFieldIdError()) + }) + + it('should return AttachmentSizeLimitExceededError when total attachment size has exceeded 20MB', async () => { + // Act + const actualResult = await getQuarantinePresignedPostData([ + { id: fieldId1, size: 2 }, + { id: fieldId2, size: 19999999 }, + ]) + + // Assert + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual( + new AttachmentSizeLimitExceededError(), + ) + }) + }) }) /** diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts new file mode 100644 index 0000000000..648fd2144d --- /dev/null +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.constants.ts @@ -0,0 +1,4 @@ +/** + * 60 seconds = 1 minute. The expiry time for presigned POST URLs. + */ +export const PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS = 60 diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 2b3ea87e01..1b170c8c75 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -7,9 +7,11 @@ import mongoose from 'mongoose' import { okAsync } from 'neverthrow' import Stripe from 'stripe' +import { featureFlags } from '../../../../../shared/constants' import { ErrorDto, FormAuthType, + FormResponseMode, FormSubmissionMetadataQueryDto, Payment, PaymentChannel, @@ -38,6 +40,7 @@ import { getFormAfterPermissionChecks } from '../../auth/auth.service' import { MalformedParametersError } from '../../core/core.errors' import { ControllerHandler } from '../../core/core.types' import { setFormTags } from '../../datadog/datadog.utils' +import { getFeatureFlag } from '../../feature-flags/feature-flags.service' import { PermissionLevel } from '../../form/admin-form/admin-form.types' import { SgidService } from '../../sgid/sgid.service' import { getOidcService } from '../../spcp/spcp.oidc.service' @@ -45,6 +48,7 @@ import { getPopulatedUserById } from '../../user/user.service' import * as VerifiedContentService from '../../verified-content/verified-content.service' import * as EncryptSubmissionMiddleware from '../encrypt-submission/encrypt-submission.middleware' import * as ReceiverMiddleware from '../receiver/receiver.middleware' +import { fileSizeLimit, fileSizeLimitBytes } from '../submission.utils' import { reportSubmissionResponseTime } from '../submissions.statsd-client' import { @@ -52,11 +56,15 @@ import { ensurePublicForm, ensureValidCaptcha, } from './encrypt-submission.ensures' -import { SubmissionFailedError } from './encrypt-submission.errors' +import { + FeatureDisabledError, + SubmissionFailedError, +} from './encrypt-submission.errors' import { addPaymentDataStream, checkFormIsEncryptMode, getEncryptedSubmissionData, + getQuarantinePresignedPostData, getSubmissionCursor, getSubmissionMetadata, getSubmissionMetadataList, @@ -67,6 +75,8 @@ import { uploadAttachments, } from './encrypt-submission.service' import { + AttachmentPresignedPostDataMapType, + AttachmentSizeMapType, SubmitEncryptModeFormHandlerRequest, SubmitEncryptModeFormHandlerType, } from './encrypt-submission.types' @@ -77,7 +87,7 @@ import { mapRouteError, } from './encrypt-submission.utils' -export const logger = createLoggerWithLabel(module) +const logger = createLoggerWithLabel(module) const EncryptSubmission = getEncryptSubmissionModel(mongoose) const EncryptPendingSubmission = getEncryptPendingSubmissionModel(mongoose) const Payment = getPaymentModel(mongoose) @@ -976,3 +986,113 @@ export const handleGetMetadata = [ }), getMetadata, ] as ControllerHandler[] + +/** + * Handler for POST /:formId/submissions/storage/get-s3-presigned-post-data + * Used by handleGetS3PresignedPostData after joi validation + * @returns 200 with array of presigned post data + * @returns 400 if ids are invalid or total file size exceeds 20MB + * @returns 500 if presigned post data cannot be retrieved or any other errors occur + * Exported for testing + */ +export const getS3PresignedPostData: ControllerHandler< + unknown, + AttachmentPresignedPostDataMapType[] | ErrorDto, + AttachmentSizeMapType[] +> = async (req, res) => { + const logMeta = { + action: 'getS3PresignedPostData', + ...createReqMeta(req), + } + + return getFeatureFlag(featureFlags.encryptionBoundaryShiftVirusScanner) + .map((virusScannerEnabled) => { + if (!virusScannerEnabled) { + logger.warn({ + message: 'Virus scanning has not been enabled.', + meta: logMeta, + }) + + const { statusCode, errorMessage } = mapRouteError( + new FeatureDisabledError(), + ) + return res.status(statusCode).send({ + message: errorMessage, + }) + } + + return getQuarantinePresignedPostData(req.body) + .map((presignedUrls) => { + logger.info({ + message: 'Successfully retrieved quarantine presigned post data.', + meta: logMeta, + }) + return res.status(StatusCodes.OK).send(presignedUrls) + }) + .mapErr((error) => { + logger.error({ + message: 'Failure getting quarantine presigned post data.', + meta: logMeta, + error, + }) + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).send({ + message: errorMessage, + }) + }) + }) + .mapErr((error) => { + logger.error({ + message: 'Error retrieving feature flags.', + meta: logMeta, + error, + }) + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).send({ + message: errorMessage, + }) + }) +} + +/** + * Custom validation function for Joi to validate that the sum of 'size' in the array of objects + * is less than or equal to total file size limit (20MB). + */ +const validateFileSizeSum = ( + value: { size: number }[], + helpers: { error: (arg0: string) => null }, +) => { + const sum = value.reduce((acc, curr) => acc + curr.size, 0) + + if (sum <= fileSizeLimitBytes(FormResponseMode.Encrypt)) { + return value // Return the validated value if the sum of 'size' is less than or equal to limit + } else { + return helpers.error('size.limit') // Return an error if the sum of 'size' is greater than limit + } +} + +// Handler for POST /:formId/submissions/storage/get-s3-presigned-post-data +export const handleGetS3PresignedPostData = [ + celebrate({ + [Segments.BODY]: Joi.array() + .items( + Joi.object().keys({ + id: Joi.string() + .regex(/^[0-9a-fA-F]{24}$/) // IDs should be MongoDB ObjectIDs + .required(), + size: Joi.number() + .max(fileSizeLimitBytes(FormResponseMode.Encrypt)) // Max attachment size is 20MB + .required(), + }), + ) + .unique('id') // IDs of each array item should be unique + .custom(validateFileSizeSum, 'Custom validation for total file size') // Custom validation to check for total file size + .messages({ + 'size.limit': `Total file size exceeds ${fileSizeLimit( + FormResponseMode.Encrypt, + )}MB`, // Custom error message for total file size + 'array.unique': 'Duplicate id(s) found', // Custom error message for duplicate IDs + }), + }), + getS3PresignedPostData, +] as ControllerHandler[] diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.ensures.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.ensures.ts index 92429d5e28..c316f5cdb6 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.ensures.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.ensures.ts @@ -1,14 +1,16 @@ import { CaptchaTypes } from '../../../../../shared/types/captcha' import { IPopulatedForm } from '../../../../types' +import { createLoggerWithLabel } from '../../../config/logger' import * as CaptchaService from '../../../services/captcha/captcha.service' import * as TurnstileService from '../../../services/turnstile/turnstile.service' import { Middleware } from '../../../utils/pipeline-middleware' import { getRequestIp } from '../../../utils/request' import * as FormService from '../../form/form.service' -import { logger } from './encrypt-submission.controller' import { mapRouteError } from './encrypt-submission.utils' +const logger = createLoggerWithLabel(module) + type FormSubmissionPipelineContext = { req: any res: any diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.errors.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.errors.ts index 7d21f92554..bbc0b25b6e 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.errors.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.errors.ts @@ -1,4 +1,6 @@ +import { FormResponseMode } from '../../../../../shared/types' import { ApplicationError } from '../../core/core.errors' +import { fileSizeLimit } from '../submission.utils' export class FormsgReqBodyExistsError extends ApplicationError { constructor( @@ -23,3 +25,27 @@ export class SubmissionFailedError extends ApplicationError { super(message) } } + +export class InvalidFieldIdError extends ApplicationError { + constructor( + message = 'Invalid field id. Field id should be a valid MongoDB ObjectId.', + ) { + super(message) + } +} + +export class AttachmentSizeLimitExceededError extends ApplicationError { + constructor( + message = `Total attachment size exceeds ${fileSizeLimit( + FormResponseMode.Encrypt, + )}MB. Please reduce your total attachment size and try again.`, + ) { + super(message) + } +} + +export class FeatureDisabledError extends ApplicationError { + constructor(message = 'This feature is disabled.') { + super(message) + } +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index 97e64f824d..7cf3f57c48 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -44,7 +44,7 @@ import { import { mapRouteError } from './encrypt-submission.utils' import IncomingEncryptSubmission from './IncomingEncryptSubmission.class' -export const logger = createLoggerWithLabel(module) +const logger = createLoggerWithLabel(module) const JoiInt = Joi.number().integer() /** @@ -153,10 +153,21 @@ export const checkNewBoundaryEnabled = async ( res: Parameters[1], next: NextFunction, ) => { + const logMeta = { + action: 'checkNewBoundaryEnabled', + ...createReqMeta(req), + } + const newBoundaryEnabled = req.formsg.featureFlags.includes( featureFlags.encryptionBoundaryShift, ) + if (!newBoundaryEnabled) { + logger.warn({ + message: 'Encryption boundary shift is not enabled.', + meta: logMeta, + }) + return res .status(StatusCodes.FORBIDDEN) .json({ message: 'This endpoint has not been enabled for this form.' }) diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts index 3d3d25bae7..fbb469b1da 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts @@ -23,6 +23,10 @@ import { import { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import { getEncryptSubmissionModel } from '../../../models/submission.server.model' +import { + createPresignedPostDataPromise, + CreatePresignedPostError, +} from '../../../utils/aws-s3' import { isMalformedDate } from '../../../utils/date' import { getMongoErrorMessage } from '../../../utils/handle-mongo-error' import { @@ -31,7 +35,6 @@ import { MalformedParametersError, PossibleDatabaseError, } from '../../core/core.errors' -import { CreatePresignedUrlError } from '../../form/admin-form/admin-form.errors' import { FormNotFoundError } from '../../form/form.errors' import * as FormService from '../../form/form.service' import { isFormEncryptMode } from '../../form/form.utils' @@ -48,10 +51,20 @@ import { SubmissionNotFoundError, } from '../submission.errors' import { sendEmailConfirmations } from '../submission.service' -import { extractEmailConfirmationData } from '../submission.utils' +import { + extractEmailConfirmationData, + fileSizeLimitBytes, +} from '../submission.utils' +import { PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS } from './encrypt-submission.constants' +import { + AttachmentSizeLimitExceededError, + InvalidFieldIdError, +} from './encrypt-submission.errors' import { AttachmentMetadata, + AttachmentPresignedPostDataMapType, + AttachmentSizeMapType, SaveEncryptSubmissionParams, } from './encrypt-submission.types' @@ -346,12 +359,12 @@ export const getSubmissionPaymentDto = ( * @param attachmentMetadata the metadata to transform * @param urlValidDuration the duration the S3 signed url will be valid for * @returns ok(map with object path replaced with their signed url counterparts) - * @returns err(CreatePresignedUrlError) if any of the signed url creation processes results in an error + * @returns err(CreatePresignedPostError) if any of the signed url creation processes results in an error */ export const transformAttachmentMetasToSignedUrls = ( attachmentMetadata: Map | undefined, urlValidDuration: number, -): ResultAsync, CreatePresignedUrlError> => { +): ResultAsync, CreatePresignedPostError> => { if (!attachmentMetadata) { return okAsync({}) } @@ -380,7 +393,7 @@ export const transformAttachmentMetasToSignedUrls = ( error, }) - return new CreatePresignedUrlError('Failed to create attachment URL') + return new CreatePresignedPostError('Failed to create attachment URL') }, ) } @@ -530,3 +543,38 @@ export const performEncryptPostSubmissionActions = ( }) }) } + +export const getQuarantinePresignedPostData = ( + attachmentSizes: AttachmentSizeMapType[], +): ResultAsync< + AttachmentPresignedPostDataMapType[], + CreatePresignedPostError +> => { + // List of attachments is looped over twice to avoid side effects of mutating variables + // to check if the attachment limits have been exceeded. + + // Step 1: Check for the total attachment size + const totalAttachmentSizeLimit = fileSizeLimitBytes(FormResponseMode.Encrypt) + const totalAttachmentSize = attachmentSizes + .map(({ size }) => size) + .reduce((prev, next) => prev + next) + if (totalAttachmentSize > totalAttachmentSizeLimit) + return errAsync(new AttachmentSizeLimitExceededError()) + + // Step 2: Create presigned post data for each attachment + return ResultAsync.combine( + attachmentSizes.map(({ id, size }) => { + if (!mongoose.isValidObjectId(id)) + return errAsync(new InvalidFieldIdError()) + + return createPresignedPostDataPromise({ + bucketName: AwsConfig.virusScannerQuarantineS3Bucket, + expiresSeconds: PRESIGNED_ATTACHMENT_POST_EXPIRY_SECS, + size, + }).map((presignedPostData) => ({ + id, + presignedPostData, + })) + }), + ) +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts index 84d1f225f7..3ce0f7c472 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts @@ -1,3 +1,6 @@ +import { PresignedPost } from 'aws-sdk/clients/s3' +import { ObjectId } from 'mongodb' + import { SubmissionErrorDto, SubmissionResponseDto, @@ -71,3 +74,13 @@ export type SubmitEncryptModeFormHandlerType = ControllerHandler< export type SubmitEncryptModeFormHandlerRequest = Parameters[0] & { formsg: FormCompleteDto } + +export type AttachmentSizeMapType = { + id: ObjectId + size: number +} + +export type AttachmentPresignedPostDataMapType = { + id: ObjectId + presignedPostData: PresignedPost +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts index 4eff7dc3ec..e64fb1e1cb 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -31,6 +31,7 @@ import { TurnstileConnectionError, VerifyTurnstileError, } from '../../../services/turnstile/turnstile.errors' +import { CreatePresignedPostError } from '../../../utils/aws-s3' import { genericMapRouteErrorTransform } from '../../../utils/error' import { AttachmentUploadError, @@ -41,7 +42,6 @@ import { EmptyErrorFieldError, MalformedParametersError, } from '../../core/core.errors' -import { CreatePresignedUrlError } from '../../form/admin-form/admin-form.errors' import { ForbiddenFormError, FormDeletedError, @@ -70,7 +70,12 @@ import { ValidateFieldError, } from '../submission.errors' -import { SubmissionFailedError } from './encrypt-submission.errors' +import { + AttachmentSizeLimitExceededError, + FeatureDisabledError, + InvalidFieldIdError, + SubmissionFailedError, +} from './encrypt-submission.errors' const logger = createLoggerWithLabel(module) @@ -122,6 +127,7 @@ const errorMapper: MapRouteError = ( statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, } + case FeatureDisabledError: case ForbiddenFormError: return { statusCode: StatusCodes.FORBIDDEN, @@ -210,7 +216,7 @@ const errorMapper: MapRouteError = ( 'The form has been updated. Please refresh and submit again.', } case PaymentNotFoundError: - case CreatePresignedUrlError: + case CreatePresignedPostError: case DatabaseError: case EmptyErrorFieldError: return { @@ -218,6 +224,8 @@ const errorMapper: MapRouteError = ( errorMessage: error.message, } case SubmissionFailedError: + case InvalidFieldIdError: + case AttachmentSizeLimitExceededError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 8fb9edf09e..02234da139 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -34,7 +34,7 @@ type ResponseModeFilterParam = { fieldType: BasicField } -const mbMultiplier = 1000000 +const MB_MULTIPLIER = 1000000 /** * Returns the file size limit in MB based on whether request is an email-mode submission @@ -224,6 +224,10 @@ export const getInvalidFileExtensions = ( return Promise.all(promises).then((results) => flattenDeep(results)) } +export const fileSizeLimitBytes = (responseMode: FormResponseMode) => { + return MB_MULTIPLIER * fileSizeLimit(responseMode) +} + /** * Checks whether the total size of attachments exceeds 7MB * @param attachments List of attachments @@ -235,7 +239,7 @@ export const areAttachmentsMoreThanLimit = ( ): boolean => { // Check if total attachments size is < 7mb const totalAttachmentSize = sumBy(attachments, (a) => a.content.byteLength) - return totalAttachmentSize > mbMultiplier * fileSizeLimit(responseMode) + return totalAttachmentSize > fileSizeLimitBytes(responseMode) } /** diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts index fd9960a15b..bd998c3034 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.form.routes.spec.ts @@ -7,7 +7,6 @@ import { errAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' import { DatabaseError } from 'src/app/modules/core/core.errors' -import { createSampleSubmissionData } from 'src/app/modules/form/form.service' import { MOCK_ACCESS_TOKEN, MOCK_AUTH_CODE, @@ -331,9 +330,6 @@ describe('public-form.form.routes', () => { const formFields = fullForm?.getPublicView().form_fields if (!formFields) return const expectedSampleData = {} - for (const field of formFields) { - createSampleSubmissionData(expectedSampleData, field) - } const expectedResponseBody = JSON.parse( JSON.stringify({ responses: expectedSampleData, diff --git a/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts index f267bc0b93..4510ef76d5 100644 --- a/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts +++ b/src/app/routes/api/v3/forms/__tests__/public-forms.submissions.routes.spec.ts @@ -3,8 +3,13 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import jwt from 'jsonwebtoken' import { omit } from 'lodash' import mongoose from 'mongoose' +import { errAsync, okAsync } from 'neverthrow' +import { featureFlags } from 'shared/constants' import session, { Session } from 'supertest-session' +import { aws } from 'src/app/config/config' +import { DatabaseError } from 'src/app/modules/core/core.errors' +import * as FeatureFlagsService from 'src/app/modules/feature-flags/feature-flags.service' import { FormFieldSchema } from 'src/types' import { FormAuthType, FormStatus } from '../../../../../../../shared/types' @@ -1228,4 +1233,1026 @@ describe('public-form.submissions.routes', () => { }) }) }) + + describe('POST /forms/:formId/submissions/storage/get-s3-presigned-post-data', () => { + const FILE_MAP_1 = { id: '64ed84955ac23100636a00a0', size: 1 } + const FILE_MAP_2 = { id: '64ed84a35ac23100636a00af', size: 19999999 } + const VALID_PAYLOAD = [FILE_MAP_1, FILE_MAP_2] + + it('should return 400 if payload is not an array', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(FILE_MAP_1) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Bad Request', + message: 'Validation failed', + statusCode: 400, + validation: { + body: expect.objectContaining({ + message: '"value" must be an array', + source: 'body', + }), + }, + }) + }) + + it('should return 400 if id is invalid', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + const INVALID_ID_PAYLOAD = JSON.parse(JSON.stringify(VALID_PAYLOAD)) + INVALID_ID_PAYLOAD[0].id = 'invalidObjectId' + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(INVALID_ID_PAYLOAD) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Bad Request', + message: 'Validation failed', + statusCode: 400, + validation: { + body: expect.objectContaining({ + keys: ['0.id'], + message: + '"[0].id" with value "invalidObjectId" fails to match the required pattern: /^[0-9a-fA-F]{24}$/', + source: 'body', + }), + }, + }) + }) + + it('should return 400 if size of a file is higher than the limit (20MB)', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + const INVALID_FILE_SIZE_PAYLOAD = JSON.parse( + JSON.stringify(VALID_PAYLOAD), + ) + INVALID_FILE_SIZE_PAYLOAD[1].size += 10000000 + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(INVALID_FILE_SIZE_PAYLOAD) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Bad Request', + message: 'Validation failed', + statusCode: 400, + validation: { + body: expect.objectContaining({ + keys: ['1.size'], + message: '"[1].size" must be less than or equal to 20000000', + source: 'body', + }), + }, + }) + }) + + it('should return 400 if size of total file size is higher than the limit (20MB)', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + const INVALID_TOTAL_FILE_SIZE_PAYLOAD = JSON.parse( + JSON.stringify(VALID_PAYLOAD), + ) + INVALID_TOTAL_FILE_SIZE_PAYLOAD[0].size += 1 + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(INVALID_TOTAL_FILE_SIZE_PAYLOAD) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ + error: 'Bad Request', + message: 'Validation failed', + statusCode: 400, + validation: { + body: expect.objectContaining({ + keys: [''], + message: 'Total file size exceeds 20MB', + source: 'body', + }), + }, + }) + }) + + it('should return 403 if virus scanning has not been enabled', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + jest + .spyOn(FeatureFlagsService, 'getFeatureFlag') + .mockReturnValue(okAsync(false)) + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(VALID_PAYLOAD) + + expect(response.status).toBe(403) + expect(response.body).toEqual({ + message: 'This feature is disabled.', + }) + }) + + it('should return 500 if feature flag retrieving fails', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + jest + .spyOn(FeatureFlagsService, 'getFeatureFlag') + .mockReturnValue(errAsync(new DatabaseError())) + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(VALID_PAYLOAD) + + expect(response.status).toBe(500) + expect(response.body).toEqual({ + message: 'Something went wrong. Please try again.', + }) + }) + + it('should return 500 if creating of presigned post data fails', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + jest + .spyOn(FeatureFlagsService, 'getFeatureFlag') + .mockReturnValue(okAsync(true)) + jest.spyOn(aws.s3, 'createPresignedPost').mockImplementationOnce(() => { + throw new Error('some error') + }) + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(VALID_PAYLOAD) + + expect(response.status).toBe(500) + expect(response.body).toEqual({ + message: 'Could not create presigned post data. Please try again.', + }) + }) + + it('should return 200 with presigned post data if virus scanning is enabled', async () => { + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + jest + .spyOn(FeatureFlagsService, 'getFeatureFlag') + .mockReturnValue(okAsync(true)) + + const expectedPresignedPostData = expect.objectContaining({ + fields: expect.objectContaining({ + Policy: expect.any(String), + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': expect.stringMatching( + /^\w+\/\d{8}\/ap-southeast-1\/s3\/aws4_request$/, + ), + 'X-Amz-Date': expect.stringMatching(/^\d{8}T\d{6}Z$/), + 'X-Amz-Signature': expect.any(String), + bucket: expect.any(String), + key: expect.any(String), + }), + url: expect.stringMatching(/^https?:\/\/\w+:?(\d*)?\/.+$/), + }) + + const response = await request + .post( + `/forms/${form._id}/submissions/storage/get-s3-presigned-post-data`, + ) + .send(VALID_PAYLOAD) + + expect(response.status).toBe(200) + expect(response.body).toEqual([ + expect.objectContaining({ + id: VALID_PAYLOAD[0].id, + presignedPostData: expectedPresignedPostData, + }), + expect.objectContaining({ + id: VALID_PAYLOAD[1].id, + presignedPostData: expectedPresignedPostData, + }), + ]) + }) + }) + + describe('POST /forms/:formId/submissions/storage', () => { + describe('Joi validation', () => { + beforeEach(() => { + jest + .spyOn(FeatureFlagsService, 'getEnabledFlags') + .mockReturnValue(okAsync([featureFlags.encryptionBoundaryShift])) + }) + + it('should return 403 when feature flag has not been enabled', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + jest + .spyOn(FeatureFlagsService, 'getEnabledFlags') + .mockReturnValueOnce(okAsync([])) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + // MOCK_RESPONSE contains all required keys + .field( + 'body', + JSON.stringify({ + responses: [MOCK_TEXTFIELD_RESPONSE], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(403) + expect(response.body).toEqual({ + message: 'This endpoint has not been enabled for this form.', + }) + }) + + it('should return 200 when submission is valid', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + // MOCK_RESPONSE contains all required keys + .field( + 'body', + JSON.stringify({ + responses: [MOCK_TEXTFIELD_RESPONSE], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 200 when answer is empty string for optional field', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + hasCaptcha: false, + status: FormStatus.Public, + form_fields: [ + { ...MOCK_TEXT_FIELD, required: false } as FormFieldSchema, + ], + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answer: '' }], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 200 when response has isHeader key', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + form_fields: [MOCK_SECTION_FIELD], + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [{ ...MOCK_SECTION_RESPONSE, isHeader: true }], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 200 when signature is empty string for optional verified field', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + form_fields: [MOCK_OPTIONAL_VERIFIED_FIELD], + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [ + { ...MOCK_OPTIONAL_VERIFIED_RESPONSE, signature: '' }, + ], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 200 when response has answerArray and no answer', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + form_fields: [MOCK_CHECKBOX_FIELD], + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [MOCK_CHECKBOX_RESPONSE], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 400 when version key is missing', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + // Note missing responses + .field( + 'body', + JSON.stringify({ responses: [MOCK_TEXTFIELD_RESPONSE] }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when responses key is missing', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + // Note missing responses + .field('body', JSON.stringify({ version: 2 })) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when response is missing _id', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [omit(MOCK_TEXTFIELD_RESPONSE, '_id')], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when response is missing fieldType', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'fieldType')], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when response has invalid fieldType', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [ + { ...MOCK_TEXTFIELD_RESPONSE, fieldType: 'definitelyInvalid' }, + ], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when response is missing answer', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [omit(MOCK_TEXTFIELD_RESPONSE, 'answer')], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when response has both answer and answerArray', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [{ ...MOCK_TEXTFIELD_RESPONSE, answerArray: [] }], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when attachment response has filename but not content', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'content'], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + + it('should return 400 when attachment response has content but not filename', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field( + 'body', + JSON.stringify({ + responses: [omit(MOCK_ATTACHMENT_RESPONSE), 'filename'], + version: 2, + }), + ) + .query({ captchaResponse: 'null', captchaType: '' }) + + // Assert + expect(response.status).toBe(400) + expect(response.body.message).toEqual('Validation failed') + }) + }) + + describe('SP, CP and MyInfo authentication', () => { + const MOCK_STORAGE_NO_RESPONSES_BODY = { + ...MOCK_NO_RESPONSES_BODY, + version: 2, + } + beforeEach(() => { + jest + .spyOn(FeatureFlagsService, 'getEnabledFlags') + .mockReturnValue(okAsync([featureFlags.encryptionBoundaryShift])) + }) + + describe('SingPass', () => { + it('should return 200 when submission is valid', async () => { + // Arrange + jest + .spyOn(SpOidcClient.prototype, 'verifyJwt') + .mockResolvedValueOnce({ + userName: 'S1234567A', + }) + + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.SP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 401 when submission does not have JWT', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.SP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + // Note cookie is not set + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has the wrong JWT type', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.SP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + // Note cookie is for CorpPass, not SingPass + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has invalid JWT', async () => { + // Arrange + // Mock auth client to return error when decoding JWT + jest + .spyOn(SpOidcClient.prototype, 'verifyJwt') + .mockRejectedValueOnce(new Error()) + + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.SP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has JWT with the wrong shape', async () => { + // Arrange + // Mock auth client to return wrong decoded shape + jest + .spyOn(SpOidcClient.prototype, 'verifyJwt') + .mockResolvedValueOnce({ + wrongKey: 'S1234567A', + }) + + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.SP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + }) + + describe('CorpPass', () => { + it('should return 200 when submission is valid', async () => { + // Arrange + mockCpClient.verifyJwt.mockResolvedValueOnce({ + userName: 'S1234567A', + userInfo: 'MyCorpPassUEN', + }) + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(response.status).toBe(200) + expect(response.body).toEqual({ + message: 'Form submission successful.', + submissionId: expect.any(String), + timestamp: expect.any(Number), + }) + }) + + it('should return 401 when submission does not have JWT', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + // Note cookie is not set + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has the wrong JWT type', async () => { + // Arrange + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + // Note cookie is for SingPass, not CorpPass + .set('Cookie', ['jwtSp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has invalid JWT', async () => { + // Arrange + // Mock auth client to return error when decoding JWT + mockCpClient.verifyJwt.mockRejectedValueOnce(new Error()) + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + + it('should return 401 when submission has JWT with the wrong shape', async () => { + // Arrange + // Mock auth client to return wrong decoded JWT shape + mockCpClient.verifyJwt.mockResolvedValueOnce({ + wrongKey: 'S1234567A', + }) + const { form } = await dbHandler.insertEncryptForm({ + formOptions: { + esrvcId: 'mockEsrvcId', + authType: FormAuthType.CP, + hasCaptcha: false, + status: FormStatus.Public, + }, + }) + + // Act + const response = await request + .post(`/forms/${form._id}/submissions/storage`) + .field('body', JSON.stringify(MOCK_STORAGE_NO_RESPONSES_BODY)) + .query({ captchaResponse: 'null', captchaType: '' }) + .set('Cookie', ['jwtCp=mockJwt']) + + // Assert + expect(response.status).toBe(401) + expect(response.body).toEqual({ + message: + 'Something went wrong with your login. Please try logging in and submitting again.', + spcpSubmissionFailure: true, + }) + }) + }) + }) + }) }) diff --git a/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts index ad1c012a9c..ac80818c99 100644 --- a/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.submissions.routes.ts @@ -65,3 +65,18 @@ PublicFormsSubmissionsRouter.route( limitRate({ max: rateLimitConfig.submissions }), EncryptSubmissionController.handleStorageSubmission, ) + +/** + * Get S3 presigned post data for attachments in a submission. + * @route POST /forms/:formId/submissions/storage/get-s3-presigned-post-data + * @param response.body.required - contains field ids and sizes of attachments + * @returns 200 - presigned post data generated + * @returns 400 - ids are invalid or attachment size exceeds limit + * @returns 500 - failed to generate presigned post data + */ +PublicFormsSubmissionsRouter.route( + '/:formId([a-fA-F0-9]{24})/submissions/storage/get-s3-presigned-post-data', +).post( + limitRate({ max: rateLimitConfig.submissions }), + EncryptSubmissionController.handleGetS3PresignedPostData, +) diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index c6df8728dd..b50557e06b 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -1580,6 +1580,7 @@ describe('mail.service', () => { const MOCK_SUBMISSION_ID = 'mockSubmissionId' const MOCK_FORM_ID = 'mockFormId' const MOCK_PAYMENT_ID = 'mockPaymentId' + const MOCK_PAYMENT_AMOUNT = 100 it('should send payment confirmation emails successfully', async () => { // Act @@ -1589,6 +1590,7 @@ describe('mail.service', () => { submissionId: MOCK_SUBMISSION_ID, formId: MOCK_FORM_ID, paymentId: MOCK_PAYMENT_ID, + paymentAmount: MOCK_PAYMENT_AMOUNT, }) // Assert @@ -1605,6 +1607,7 @@ describe('mail.service', () => { submissionId: MOCK_SUBMISSION_ID, formId: MOCK_FORM_ID, paymentId: MOCK_PAYMENT_ID, + paymentAmount: MOCK_PAYMENT_AMOUNT, }) // Assert diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index 9c1064959a..7651b1948c 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -6,6 +6,7 @@ import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' +import { centsToDollars } from '../../../../shared/utils/payments' import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' import { HASH_EXPIRE_AFTER_SECONDS, @@ -800,12 +801,14 @@ export class MailService { submissionId, formId, paymentId, + paymentAmount, }: { email: string formTitle: string submissionId: string formId: string paymentId: string + paymentAmount: number }): ResultAsync => { const htmlData: PaymentConfirmationData = { formTitle: formTitle, @@ -815,6 +818,7 @@ export class MailService { formId, paymentId, )}`, + amountPaid: centsToDollars(paymentAmount), } return generatePaymentConfirmationHtml({ htmlData }).andThen((html) => { const mail: MailOptions = { diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index 6b7d922646..224bd7be0c 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -121,6 +121,7 @@ export type PaymentConfirmationData = { formTitle: string submissionId: string invoiceUrl: string + amountPaid: string } export type IssueReportedNotificationData = { diff --git a/src/app/utils/aws-s3.ts b/src/app/utils/aws-s3.ts new file mode 100644 index 0000000000..c6d071655c --- /dev/null +++ b/src/app/utils/aws-s3.ts @@ -0,0 +1,73 @@ +import { PresignedPost } from 'aws-sdk/clients/s3' +import crypto from 'crypto' +import { ResultAsync } from 'neverthrow' + +import { aws as AwsConfig } from '../config/config' +import { createLoggerWithLabel } from '../config/logger' +import { ApplicationError } from '../modules/core/core.errors' + +const logger = createLoggerWithLabel(module) + +export class CreatePresignedPostError extends ApplicationError { + constructor( + message = 'Could not create presigned post data. Please try again.', + ) { + super(message) + } +} + +type CreatePresignedPostDataParams = { + bucketName: string + expiresSeconds: number + size: number + key?: string + fileMd5Hash?: string + fileType?: string + acl?: string +} + +export const createPresignedPostDataPromise = ( + params: CreatePresignedPostDataParams, +) => { + return ResultAsync.fromPromise( + new Promise((resolve, reject) => { + AwsConfig.s3.createPresignedPost( + { + Bucket: params.bucketName, + Expires: params.expiresSeconds, + Conditions: [ + // Content length restrictions: 0 to MAX_UPLOAD_FILE_SIZE. + ['content-length-range', 0, params.size], + ], + Fields: { + key: params.key ?? crypto.randomUUID(), + ...(params.acl ? { acl: params.acl } : undefined), + ...(params.fileMd5Hash + ? { 'Content-MD5': params.fileMd5Hash } + : undefined), + ...(params.fileType + ? { 'Content-Type': params.fileType } + : undefined), + }, + }, + (err, data) => { + if (err) { + return reject(err) + } + return resolve(data) + }, + ) + }), + (error) => { + logger.error({ + message: 'Error encountered when creating presigned POST data', + meta: { + action: 'createPresignedPostDataPromise', + }, + error, + }) + + return new CreatePresignedPostError() + }, + ) +} diff --git a/src/app/views/templates/payment-confirmation.view.html b/src/app/views/templates/payment-confirmation.view.html index 6babd8b889..44b77de53f 100644 --- a/src/app/views/templates/payment-confirmation.view.html +++ b/src/app/views/templates/payment-confirmation.view.html @@ -1,12 +1,126 @@ - -

Hello there,

-

- Your payment on <%= appName %> form: <%= formTitle %> has been received - successfully. Your response ID is <%= submissionId %> and your proof of - payment can be found here. -

-

Regards,
<%= appName %> team

+ + + + +
+

+ +

+

+ Your payment on <%= formTitle %> has been received successfully. +

+
+

+ Form: + <%= formTitle %> +

+

+ Response ID: + <%= submissionId %> +

+

+ Amount paid: + S$<%= amountPaid %> +

+

+ Please visit the link below for your proof of payment. +

+
+ + + +
+
+

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

+

+ <%= invoiceUrl %> +

+
+