@defra/forms-engine-plugin 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/package.json +3 -2
  2. package/src/client/javascripts/application.js +87 -0
  3. package/src/client/javascripts/file-upload.js +386 -0
  4. package/src/client/stylesheets/_code.scss +33 -0
  5. package/src/client/stylesheets/_govuk-frontend.scss +4 -0
  6. package/src/client/stylesheets/_prose.scss +56 -0
  7. package/src/client/stylesheets/_service-banner.scss +24 -0
  8. package/src/client/stylesheets/_summary-list.scss +28 -0
  9. package/src/client/stylesheets/_tag-env.scss +24 -0
  10. package/src/client/stylesheets/application.scss +14 -0
  11. package/src/common/cookies.js +58 -0
  12. package/src/common/cookies.test.js +23 -0
  13. package/src/common/types.js +5 -0
  14. package/src/config/index.ts +271 -0
  15. package/src/index.ts +31 -0
  16. package/src/server/common/helpers/logging/logger-options.test.ts +50 -0
  17. package/src/server/common/helpers/logging/logger-options.ts +46 -0
  18. package/src/server/common/helpers/logging/logger.ts +7 -0
  19. package/src/server/common/helpers/logging/request-logger.ts +9 -0
  20. package/src/server/common/helpers/logging/request-tracing.js +10 -0
  21. package/src/server/common/helpers/redis-client.js +70 -0
  22. package/src/server/constants.js +1 -0
  23. package/src/server/forms/README.md +10 -0
  24. package/src/server/forms/components.json +1015 -0
  25. package/src/server/forms/report-a-terrorist.json +270 -0
  26. package/src/server/forms/runner-components-test.json +365 -0
  27. package/src/server/forms/test.json +581 -0
  28. package/src/server/index.test.ts +582 -0
  29. package/src/server/index.ts +140 -0
  30. package/src/server/plugins/blankie.test.ts +73 -0
  31. package/src/server/plugins/blankie.ts +48 -0
  32. package/src/server/plugins/crumb.ts +20 -0
  33. package/src/server/plugins/engine/README.md +87 -0
  34. package/src/server/plugins/engine/components/AutocompleteField.test.ts +294 -0
  35. package/src/server/plugins/engine/components/AutocompleteField.ts +49 -0
  36. package/src/server/plugins/engine/components/CheckboxesField.test.ts +379 -0
  37. package/src/server/plugins/engine/components/CheckboxesField.ts +106 -0
  38. package/src/server/plugins/engine/components/ComponentBase.ts +97 -0
  39. package/src/server/plugins/engine/components/ComponentCollection.ts +278 -0
  40. package/src/server/plugins/engine/components/DatePartsField.test.ts +822 -0
  41. package/src/server/plugins/engine/components/DatePartsField.ts +264 -0
  42. package/src/server/plugins/engine/components/Details.test.ts +49 -0
  43. package/src/server/plugins/engine/components/Details.ts +30 -0
  44. package/src/server/plugins/engine/components/EmailAddressField.test.ts +395 -0
  45. package/src/server/plugins/engine/components/EmailAddressField.ts +55 -0
  46. package/src/server/plugins/engine/components/FileUploadField.test.ts +778 -0
  47. package/src/server/plugins/engine/components/FileUploadField.ts +262 -0
  48. package/src/server/plugins/engine/components/FormComponent.ts +249 -0
  49. package/src/server/plugins/engine/components/Html.test.ts +48 -0
  50. package/src/server/plugins/engine/components/Html.ts +29 -0
  51. package/src/server/plugins/engine/components/InsetText.test.ts +48 -0
  52. package/src/server/plugins/engine/components/InsetText.ts +27 -0
  53. package/src/server/plugins/engine/components/List.test.ts +76 -0
  54. package/src/server/plugins/engine/components/List.ts +72 -0
  55. package/src/server/plugins/engine/components/ListFormComponent.ts +140 -0
  56. package/src/server/plugins/engine/components/MonthYearField.test.ts +567 -0
  57. package/src/server/plugins/engine/components/MonthYearField.ts +222 -0
  58. package/src/server/plugins/engine/components/MultilineTextField.test.ts +558 -0
  59. package/src/server/plugins/engine/components/MultilineTextField.ts +138 -0
  60. package/src/server/plugins/engine/components/NumberField.test.ts +701 -0
  61. package/src/server/plugins/engine/components/NumberField.ts +163 -0
  62. package/src/server/plugins/engine/components/RadiosField.test.ts +288 -0
  63. package/src/server/plugins/engine/components/RadiosField.ts +24 -0
  64. package/src/server/plugins/engine/components/SelectField.test.ts +288 -0
  65. package/src/server/plugins/engine/components/SelectField.ts +47 -0
  66. package/src/server/plugins/engine/components/SelectionControlField.ts +43 -0
  67. package/src/server/plugins/engine/components/TelephoneNumberField.test.ts +356 -0
  68. package/src/server/plugins/engine/components/TelephoneNumberField.ts +67 -0
  69. package/src/server/plugins/engine/components/TextField.test.ts +489 -0
  70. package/src/server/plugins/engine/components/TextField.ts +96 -0
  71. package/src/server/plugins/engine/components/UkAddressField.test.ts +623 -0
  72. package/src/server/plugins/engine/components/UkAddressField.ts +172 -0
  73. package/src/server/plugins/engine/components/YesNoField.test.ts +248 -0
  74. package/src/server/plugins/engine/components/YesNoField.ts +31 -0
  75. package/src/server/plugins/engine/components/constants.ts +1 -0
  76. package/src/server/plugins/engine/components/helpers.ts +330 -0
  77. package/src/server/plugins/engine/components/index.ts +24 -0
  78. package/src/server/plugins/engine/components/types.ts +117 -0
  79. package/src/server/plugins/engine/configureEnginePlugin.ts +47 -0
  80. package/src/server/plugins/engine/helpers.test.ts +791 -0
  81. package/src/server/plugins/engine/helpers.ts +379 -0
  82. package/src/server/plugins/engine/index.ts +7 -0
  83. package/src/server/plugins/engine/models/FormModel.test.ts +42 -0
  84. package/src/server/plugins/engine/models/FormModel.ts +443 -0
  85. package/src/server/plugins/engine/models/RepeatingSummaryViewModel.ts +0 -0
  86. package/src/server/plugins/engine/models/Section.ts +0 -0
  87. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +209 -0
  88. package/src/server/plugins/engine/models/SummaryViewModel.ts +220 -0
  89. package/src/server/plugins/engine/models/index.ts +2 -0
  90. package/src/server/plugins/engine/models/types.ts +114 -0
  91. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +143 -0
  92. package/src/server/plugins/engine/outputFormatters/human/v1.ts +73 -0
  93. package/src/server/plugins/engine/outputFormatters/index.test.ts +17 -0
  94. package/src/server/plugins/engine/outputFormatters/index.ts +44 -0
  95. package/src/server/plugins/engine/outputFormatters/machine/v1.test.ts +229 -0
  96. package/src/server/plugins/engine/outputFormatters/machine/v1.ts +140 -0
  97. package/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +229 -0
  98. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +153 -0
  99. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +1108 -0
  100. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +446 -0
  101. package/src/server/plugins/engine/pageControllers/PageController.test.ts +205 -0
  102. package/src/server/plugins/engine/pageControllers/PageController.ts +176 -0
  103. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +1264 -0
  104. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +561 -0
  105. package/src/server/plugins/engine/pageControllers/README.md +28 -0
  106. package/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +264 -0
  107. package/src/server/plugins/engine/pageControllers/RepeatPageController.ts +458 -0
  108. package/src/server/plugins/engine/pageControllers/StartPageController.ts +18 -0
  109. package/src/server/plugins/engine/pageControllers/StatusPageController.ts +50 -0
  110. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +261 -0
  111. package/src/server/plugins/engine/pageControllers/TerminalController.test.ts +28 -0
  112. package/src/server/plugins/engine/pageControllers/TerminalPageController.ts +19 -0
  113. package/src/server/plugins/engine/pageControllers/helpers.test.ts +198 -0
  114. package/src/server/plugins/engine/pageControllers/helpers.ts +101 -0
  115. package/src/server/plugins/engine/pageControllers/index.ts +10 -0
  116. package/src/server/plugins/engine/pageControllers/validationOptions.ts +89 -0
  117. package/src/server/plugins/engine/plugin.ts +673 -0
  118. package/src/server/plugins/engine/services/formSubmissionService.js +46 -0
  119. package/src/server/plugins/engine/services/formsService.js +46 -0
  120. package/src/server/plugins/engine/services/formsService.test.js +90 -0
  121. package/src/server/plugins/engine/services/index.js +3 -0
  122. package/src/server/plugins/engine/services/notifyService.test.ts +132 -0
  123. package/src/server/plugins/engine/services/notifyService.ts +64 -0
  124. package/src/server/plugins/engine/services/uploadService.js +60 -0
  125. package/src/server/plugins/engine/types.ts +315 -0
  126. package/src/server/plugins/engine/views/components/autocompletefield.html +5 -0
  127. package/src/server/plugins/engine/views/components/checkboxesfield.html +5 -0
  128. package/src/server/plugins/engine/views/components/datepartsfield.html +5 -0
  129. package/src/server/plugins/engine/views/components/details.html +6 -0
  130. package/src/server/plugins/engine/views/components/emailaddressfield.html +5 -0
  131. package/src/server/plugins/engine/views/components/fileuploadfield-key.html +8 -0
  132. package/src/server/plugins/engine/views/components/fileuploadfield-value.html +3 -0
  133. package/src/server/plugins/engine/views/components/fileuploadfield.html +24 -0
  134. package/src/server/plugins/engine/views/components/html.html +3 -0
  135. package/src/server/plugins/engine/views/components/insettext.html +7 -0
  136. package/src/server/plugins/engine/views/components/list.html +36 -0
  137. package/src/server/plugins/engine/views/components/monthyearfield.html +5 -0
  138. package/src/server/plugins/engine/views/components/multilinetextfield.html +10 -0
  139. package/src/server/plugins/engine/views/components/numberfield.html +5 -0
  140. package/src/server/plugins/engine/views/components/radiosfield.html +5 -0
  141. package/src/server/plugins/engine/views/components/selectfield.html +5 -0
  142. package/src/server/plugins/engine/views/components/telephonenumberfield.html +5 -0
  143. package/src/server/plugins/engine/views/components/textfield.html +5 -0
  144. package/src/server/plugins/engine/views/components/ukaddressfield.html +25 -0
  145. package/src/server/plugins/engine/views/components/yesnofield.html +5 -0
  146. package/src/server/plugins/engine/views/file-upload.html +45 -0
  147. package/src/server/plugins/engine/views/index.html +39 -0
  148. package/src/server/plugins/engine/views/item-delete.html +56 -0
  149. package/src/server/plugins/engine/views/partials/components.html +6 -0
  150. package/src/server/plugins/engine/views/partials/conditional-components.html +3 -0
  151. package/src/server/plugins/engine/views/partials/debug.html +44 -0
  152. package/src/server/plugins/engine/views/partials/form.html +15 -0
  153. package/src/server/plugins/engine/views/partials/heading.html +16 -0
  154. package/src/server/plugins/engine/views/partials/preview-banner.html +32 -0
  155. package/src/server/plugins/engine/views/partials/preview-banner.test.js +122 -0
  156. package/src/server/plugins/engine/views/partials/warn-missing-notification-email.html +10 -0
  157. package/src/server/plugins/engine/views/repeat-list-summary.html +53 -0
  158. package/src/server/plugins/errorPages.ts +58 -0
  159. package/src/server/plugins/nunjucks/context.js +88 -0
  160. package/src/server/plugins/nunjucks/context.test.js +142 -0
  161. package/src/server/plugins/nunjucks/enviroment.test.js +201 -0
  162. package/src/server/plugins/nunjucks/environment.js +116 -0
  163. package/src/server/plugins/nunjucks/filters/answer.js +27 -0
  164. package/src/server/plugins/nunjucks/filters/answer.test.js +89 -0
  165. package/src/server/plugins/nunjucks/filters/evaluate.js +21 -0
  166. package/src/server/plugins/nunjucks/filters/field.js +28 -0
  167. package/src/server/plugins/nunjucks/filters/field.test.js +75 -0
  168. package/src/server/plugins/nunjucks/filters/highlight.js +11 -0
  169. package/src/server/plugins/nunjucks/filters/href.js +30 -0
  170. package/src/server/plugins/nunjucks/filters/href.test.js +80 -0
  171. package/src/server/plugins/nunjucks/filters/index.js +8 -0
  172. package/src/server/plugins/nunjucks/filters/inspect.js +15 -0
  173. package/src/server/plugins/nunjucks/filters/page.js +24 -0
  174. package/src/server/plugins/nunjucks/filters/page.test.js +65 -0
  175. package/src/server/plugins/nunjucks/index.js +3 -0
  176. package/src/server/plugins/nunjucks/plugin.js +40 -0
  177. package/src/server/plugins/nunjucks/render.js +42 -0
  178. package/src/server/plugins/nunjucks/types.js +40 -0
  179. package/src/server/plugins/pulse.ts +11 -0
  180. package/src/server/plugins/router.ts +201 -0
  181. package/src/server/plugins/session.ts +28 -0
  182. package/src/server/routes/health.js +13 -0
  183. package/src/server/routes/health.test.js +35 -0
  184. package/src/server/routes/index.test.ts +125 -0
  185. package/src/server/routes/index.ts +2 -0
  186. package/src/server/routes/public.ts +47 -0
  187. package/src/server/routes/types.ts +48 -0
  188. package/src/server/schemas/index.ts +34 -0
  189. package/src/server/secure-context.js +43 -0
  190. package/src/server/services/cacheService.test.ts +276 -0
  191. package/src/server/services/cacheService.ts +131 -0
  192. package/src/server/services/httpService.test.js +491 -0
  193. package/src/server/services/httpService.ts +50 -0
  194. package/src/server/services/index.ts +1 -0
  195. package/src/server/types.ts +54 -0
  196. package/src/server/utils/notify.test.ts +37 -0
  197. package/src/server/utils/notify.ts +50 -0
  198. package/src/server/utils/secure-context/get-trust-store-certs.js +11 -0
  199. package/src/server/utils/secure-context/get-trust-store-certs.test.js +19 -0
  200. package/src/server/utils/utils.js +24 -0
  201. package/src/server/utils/utils.test.js +54 -0
  202. package/src/server/views/404.html +16 -0
  203. package/src/server/views/500.html +19 -0
  204. package/src/server/views/components/debug/macro.njk +3 -0
  205. package/src/server/views/components/debug/template.njk +13 -0
  206. package/src/server/views/components/service-banner/macro.njk +3 -0
  207. package/src/server/views/components/service-banner/template.njk +20 -0
  208. package/src/server/views/components/service-banner/template.test.js +43 -0
  209. package/src/server/views/components/tag-env/macro.njk +3 -0
  210. package/src/server/views/components/tag-env/template.njk +30 -0
  211. package/src/server/views/components/tag-env/template.test.js +66 -0
  212. package/src/server/views/confirmation.html +19 -0
  213. package/src/server/views/help/accessibility-statement.html +58 -0
  214. package/src/server/views/help/cookie-preferences.html +57 -0
  215. package/src/server/views/help/cookies.html +71 -0
  216. package/src/server/views/help/get-support.html +37 -0
  217. package/src/server/views/help/privacy-notice.html +68 -0
  218. package/src/server/views/help/terms-and-conditions.html +83 -0
  219. package/src/server/views/layout.html +199 -0
  220. package/src/server/views/summary.html +50 -0
  221. package/src/typings/hapi/index.d.ts +95 -0
  222. package/src/typings/hapi-tracing/index.d.ts +6 -0
  223. package/src/typings/index.d.ts +3 -0
  224. package/src/typings/joi/index.d.ts +22 -0
@@ -0,0 +1,46 @@
1
+ import { formMetadataSchema } from '@defra/forms-model'
2
+
3
+ import { config } from '~/src/config/index.js'
4
+ import { FormStatus } from '~/src/server/routes/types.js'
5
+ import { getJson } from '~/src/server/services/httpService.js'
6
+
7
+ /**
8
+ * Retrieves a form definition from the form manager for a given slug
9
+ * @param {string} slug - the slug of the form
10
+ */
11
+ export async function getFormMetadata(slug) {
12
+ const getJsonByType = /** @type {typeof getJson<FormMetadata>} */ (getJson)
13
+
14
+ const { payload: metadata } = await getJsonByType(
15
+ `${config.get('managerUrl')}/forms/slug/${slug}`
16
+ )
17
+
18
+ // Run it through the schema to coerce dates
19
+ const result = formMetadataSchema.validate(metadata)
20
+
21
+ if (result.error) {
22
+ throw result.error
23
+ }
24
+
25
+ return result.value
26
+ }
27
+
28
+ /**
29
+ * Retrieves a form definition from the form manager for a given id
30
+ * @param {string} id - the id of the form
31
+ * @param {FormStatus} state - the state of the form
32
+ */
33
+ export async function getFormDefinition(id, state) {
34
+ const getJsonByType = /** @type {typeof getJson<FormDefinition>} */ (getJson)
35
+
36
+ const suffix = state === FormStatus.Draft ? `/${state}` : ''
37
+ const { payload: definition } = await getJsonByType(
38
+ `${config.get('managerUrl')}/forms/${id}/definition${suffix}`
39
+ )
40
+
41
+ return definition
42
+ }
43
+
44
+ /**
45
+ * @import { FormDefinition, FormMetadata } from '@defra/forms-model'
46
+ */
@@ -0,0 +1,90 @@
1
+ import { StatusCodes } from 'http-status-codes'
2
+
3
+ import {
4
+ getFormDefinition,
5
+ getFormMetadata
6
+ } from '~/src/server/plugins/engine/services/formsService.js'
7
+ import { FormStatus } from '~/src/server/routes/types.js'
8
+ import { getJson } from '~/src/server/services/httpService.js'
9
+ import * as fixtures from '~/test/fixtures/index.js'
10
+
11
+ const { MANAGER_URL } = process.env
12
+
13
+ jest.mock('~/src/server/services/httpService')
14
+
15
+ describe('Forms service', () => {
16
+ const { definition, metadata } = fixtures.form
17
+
18
+ describe('getFormMetadata', () => {
19
+ beforeEach(() => {
20
+ jest.mocked(getJson).mockResolvedValue({
21
+ res: /** @type {IncomingMessage} */ ({
22
+ statusCode: StatusCodes.OK
23
+ }),
24
+ payload: metadata
25
+ })
26
+ })
27
+
28
+ it('requests JSON via form slug', async () => {
29
+ await getFormMetadata(metadata.slug)
30
+
31
+ expect(getJson).toHaveBeenCalledWith(
32
+ `${MANAGER_URL}/forms/slug/${metadata.slug}`
33
+ )
34
+ })
35
+
36
+ it('coerces timestamps from string to Date', async () => {
37
+ const payload = {
38
+ ...structuredClone(metadata),
39
+
40
+ // JSON payload uses string dates in transit
41
+ createdAt: metadata.createdAt.toISOString(),
42
+ updatedAt: metadata.updatedAt.toISOString()
43
+ }
44
+
45
+ jest.mocked(getJson).mockResolvedValue({
46
+ res: /** @type {IncomingMessage} */ ({
47
+ statusCode: StatusCodes.OK
48
+ }),
49
+ payload
50
+ })
51
+
52
+ await expect(getFormMetadata(metadata.slug)).resolves.toEqual({
53
+ ...metadata,
54
+ createdAt: expect.any(Date),
55
+ updatedAt: expect.any(Date)
56
+ })
57
+ })
58
+ })
59
+
60
+ describe('getFormDefinition', () => {
61
+ beforeEach(() => {
62
+ jest.mocked(getJson).mockResolvedValue({
63
+ res: /** @type {IncomingMessage} */ ({
64
+ statusCode: StatusCodes.OK
65
+ }),
66
+ payload: definition
67
+ })
68
+ })
69
+
70
+ it('requests JSON via form ID (draft)', async () => {
71
+ await getFormDefinition(metadata.id, FormStatus.Draft)
72
+
73
+ expect(getJson).toHaveBeenCalledWith(
74
+ `${MANAGER_URL}/forms/${metadata.id}/definition/draft`
75
+ )
76
+ })
77
+
78
+ it('requests JSON via form ID (live)', async () => {
79
+ await getFormDefinition(metadata.id, FormStatus.Live)
80
+
81
+ expect(getJson).toHaveBeenCalledWith(
82
+ `${MANAGER_URL}/forms/${metadata.id}/definition`
83
+ )
84
+ })
85
+ })
86
+ })
87
+
88
+ /**
89
+ * @import { IncomingMessage } from 'node:http'
90
+ */
@@ -0,0 +1,3 @@
1
+ export * as formsService from '~/src/server/plugins/engine/services/formsService.js'
2
+ export * as formSubmissionService from '~/src/server/plugins/engine/services/formSubmissionService.js'
3
+ export * as outputService from '~/src/server/plugins/engine/services/notifyService.js'
@@ -0,0 +1,132 @@
1
+ import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
2
+ import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
3
+ import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
4
+ import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js'
5
+ import { submit } from '~/src/server/plugins/engine/services/notifyService.js'
6
+ import {
7
+ FormStatus,
8
+ type FormRequestPayload
9
+ } from '~/src/server/routes/types.js'
10
+ import { sendNotification } from '~/src/server/utils/notify.js'
11
+
12
+ jest.mock('~/src/server/utils/notify')
13
+ jest.mock('~/src/server/plugins/engine/helpers')
14
+ jest.mock('~/src/server/plugins/engine/outputFormatters/index')
15
+
16
+ describe('notifyService', () => {
17
+ const submitResponse = {
18
+ message: 'Submit completed',
19
+ result: {
20
+ files: {
21
+ main: '00000000-0000-0000-0000-000000000000',
22
+ repeaters: {
23
+ pizza: '11111111-1111-1111-1111-111111111111'
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ const items: DetailItem[] = []
30
+
31
+ const mockRequest: FormRequestPayload = jest.mocked<FormRequestPayload>({
32
+ path: 'test',
33
+ logger: {
34
+ info: jest.fn()
35
+ }
36
+ } as unknown as FormRequestPayload)
37
+ let model: FormModel
38
+ const sendNotificationMock = jest.mocked(sendNotification)
39
+
40
+ beforeEach(() => {
41
+ jest.resetAllMocks()
42
+ })
43
+
44
+ it('creates a subject line for real forms', async () => {
45
+ model = {
46
+ name: 'foobar',
47
+ def: {
48
+ output: {
49
+ audience: 'human',
50
+ version: '1'
51
+ }
52
+ }
53
+ } as FormModel
54
+
55
+ jest.mocked(checkFormStatus).mockReturnValue({
56
+ isPreview: false,
57
+ state: FormStatus.Draft
58
+ })
59
+ jest.mocked(getFormatter).mockReturnValue(() => 'dummy-live')
60
+
61
+ await submit(mockRequest, model, 'test@defra.gov.uk', items, submitResponse)
62
+
63
+ expect(sendNotificationMock).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ personalisation: {
66
+ subject: `Form submission: foobar`,
67
+ body: 'dummy-live'
68
+ }
69
+ })
70
+ )
71
+ })
72
+
73
+ it('creates a subject line for preview forms', async () => {
74
+ model = {
75
+ name: 'foobar',
76
+ def: {
77
+ output: {
78
+ audience: 'human',
79
+ version: '1'
80
+ }
81
+ }
82
+ } as FormModel
83
+
84
+ jest.mocked(checkFormStatus).mockReturnValue({
85
+ isPreview: true,
86
+ state: FormStatus.Draft
87
+ })
88
+ jest.mocked(getFormatter).mockReturnValue(() => 'dummy-preview')
89
+
90
+ await submit(mockRequest, model, 'test@defra.gov.uk', items, submitResponse)
91
+
92
+ expect(sendNotificationMock).toHaveBeenCalledWith(
93
+ expect.objectContaining({
94
+ personalisation: {
95
+ subject: `TEST FORM SUBMISSION: foobar`,
96
+ body: 'dummy-preview'
97
+ }
98
+ })
99
+ )
100
+ })
101
+
102
+ it('base64 encodes form data when aimed at machines', async () => {
103
+ model = {
104
+ name: 'foobar',
105
+ def: {
106
+ output: {
107
+ audience: 'machine',
108
+ version: '1'
109
+ }
110
+ }
111
+ } as FormModel
112
+
113
+ jest.mocked(checkFormStatus).mockReturnValue({
114
+ isPreview: true,
115
+ state: FormStatus.Draft
116
+ })
117
+ jest
118
+ .mocked(getFormatter)
119
+ .mockReturnValue(() => 'dummy-preview " Hello world \' !@/')
120
+
121
+ await submit(mockRequest, model, 'test@defra.gov.uk', items, submitResponse)
122
+
123
+ expect(sendNotificationMock).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ personalisation: {
126
+ subject: `TEST FORM SUBMISSION: foobar`,
127
+ body: 'ZHVtbXktcHJldmlldyAiIEhlbGxvIHdvcmxkICcgIUAv'
128
+ }
129
+ })
130
+ )
131
+ })
132
+ })
@@ -0,0 +1,64 @@
1
+ import { type SubmitResponsePayload } from '@defra/forms-model'
2
+
3
+ import { config } from '~/src/config/index.js'
4
+ import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers.js'
5
+ import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
6
+ import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
7
+ import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
8
+ import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js'
9
+ import { type FormRequestPayload } from '~/src/server/routes/types.js'
10
+ import { sendNotification } from '~/src/server/utils/notify.js'
11
+
12
+ const templateId = config.get('notifyTemplateId')
13
+
14
+ export async function submit(
15
+ request: FormRequestPayload,
16
+ model: FormModel,
17
+ emailAddress: string,
18
+ items: DetailItem[],
19
+ submitResponse: SubmitResponsePayload
20
+ ) {
21
+ const logTags = ['submit', 'email']
22
+ const { path } = request
23
+ const formStatus = checkFormStatus(path)
24
+
25
+ // Get submission email personalisation
26
+ request.logger.info(logTags, 'Getting personalisation data')
27
+
28
+ const formName = escapeMarkdown(model.name)
29
+ const subject = formStatus.isPreview
30
+ ? `TEST FORM SUBMISSION: ${formName}`
31
+ : `Form submission: ${formName}`
32
+
33
+ const outputAudience = model.def.output?.audience ?? 'human'
34
+ const outputVersion = model.def.output?.version ?? '1'
35
+
36
+ const outputFormatter = getFormatter(outputAudience, outputVersion)
37
+ let body = outputFormatter(items, model, submitResponse, formStatus)
38
+
39
+ // GOV.UK Notify transforms quotes into curly quotes, so we can't just send the raw payload
40
+ // This is logic specific to Notify, so we include the logic here rather than in the formatter
41
+ if (outputAudience === 'machine') {
42
+ body = Buffer.from(body).toString('base64')
43
+ }
44
+
45
+ request.logger.info(logTags, 'Sending email')
46
+
47
+ try {
48
+ // Send submission email
49
+ await sendNotification({
50
+ templateId,
51
+ emailAddress,
52
+ personalisation: {
53
+ subject,
54
+ body
55
+ }
56
+ })
57
+
58
+ request.logger.info(logTags, 'Email sent successfully')
59
+ } catch (err) {
60
+ request.logger.error(logTags, 'Error sending email', err)
61
+
62
+ throw err
63
+ }
64
+ }
@@ -0,0 +1,60 @@
1
+ import { config } from '~/src/config/index.js'
2
+ import { getJson, postJson } from '~/src/server/services/httpService.js'
3
+
4
+ const uploaderUrl = config.get('uploaderUrl')
5
+ const submissionUrl = config.get('submissionUrl')
6
+ const uploaderBucketName = config.get('uploaderBucketName')
7
+ const stagingPrefix = config.get('stagingPrefix')
8
+
9
+ /**
10
+ * Initiates a CDP file upload
11
+ * @param {string} path - the path of the page in the form
12
+ * @param {string} retrievalKey - the retrieval key for the files
13
+ * @param {string} [mimeTypes] - the csv string of accepted mimeTypes
14
+ */
15
+ export async function initiateUpload(path, retrievalKey, mimeTypes) {
16
+ const postJsonByType =
17
+ /** @type {typeof postJson<UploadInitiateResponse>} */ (postJson)
18
+
19
+ const payload = {
20
+ redirect: path,
21
+ callback: `${submissionUrl}/file`,
22
+ s3Bucket: uploaderBucketName,
23
+ s3Path: stagingPrefix,
24
+ metadata: {
25
+ retrievalKey
26
+ },
27
+ mimeTypes: mimeTypes
28
+ ?.split(',')
29
+ .map((type) => type.trim())
30
+ .filter((type) => type !== '')
31
+ // maxFileSize: 25 * 1000 * 1000
32
+ }
33
+
34
+ const { payload: initiate } = await postJsonByType(
35
+ `${uploaderUrl}/initiate`,
36
+ { payload }
37
+ )
38
+
39
+ return initiate
40
+ }
41
+
42
+ /**
43
+ * Get the status of a CDP file upload
44
+ * @param {string} uploadId - the ID of the upload
45
+ */
46
+ export async function getUploadStatus(uploadId) {
47
+ const getJsonByType = /** @type {typeof getJson<UploadStatusResponse>} */ (
48
+ getJson
49
+ )
50
+
51
+ const { payload: status } = await getJsonByType(
52
+ `${uploaderUrl}/status/${uploadId}`
53
+ )
54
+
55
+ return status
56
+ }
57
+
58
+ /**
59
+ * @import { UploadInitiateResponse, UploadStatusResponse } from '~/src/server/plugins/engine/types.js'
60
+ */
@@ -0,0 +1,315 @@
1
+ import {
2
+ type ComponentDef,
3
+ type Item,
4
+ type List,
5
+ type Page
6
+ } from '@defra/forms-model'
7
+ import { type ValidationErrorItem } from 'joi'
8
+
9
+ import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
10
+ import { type Component } from '~/src/server/plugins/engine/components/helpers.js'
11
+ import {
12
+ type BackLink,
13
+ type ComponentText,
14
+ type ComponentViewModel
15
+ } from '~/src/server/plugins/engine/components/types.js'
16
+ import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
17
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'
18
+ import { type ViewContext } from '~/src/server/plugins/nunjucks/types.js'
19
+ import { type FormAction, type FormRequest } from '~/src/server/routes/types.js'
20
+
21
+ /**
22
+ * Form submission state stores the following in Redis:
23
+ * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }`
24
+ * a) . e.g:
25
+ * ```ts
26
+ * {
27
+ * _C9PRHmsgt: 'Ben',
28
+ * WfLk9McjzX: 'Music',
29
+ * IK7jkUFCBL: 'Royal Academy of Music'
30
+ * }
31
+ * ```
32
+ *
33
+ * b)
34
+ * ```ts
35
+ * {
36
+ * checkBeforeYouStart: { ukPassport: true },
37
+ * applicantDetails: {
38
+ * numberOfApplicants: 1,
39
+ * phoneNumber: '77777777',
40
+ * emailAddress: 'aaa@aaa.com'
41
+ * },
42
+ * applicantOneDetails: {
43
+ * firstName: 'a',
44
+ * middleName: 'a',
45
+ * lastName: 'a',
46
+ * address: { addressLine1: 'a', addressLine2: 'a', town: 'a', postcode: 'a' }
47
+ * }
48
+ * }
49
+ * ```
50
+ */
51
+
52
+ /**
53
+ * Form submission state
54
+ */
55
+ export type FormSubmissionState = {
56
+ upload?: Record<string, TempFileState>
57
+ } & FormState
58
+
59
+ export interface FormSubmissionError
60
+ extends Pick<ValidationErrorItem, 'context' | 'path'> {
61
+ href: string // e.g: '#dateField__day'
62
+ name: string // e.g: 'dateField__day'
63
+ text: string // e.g: 'Date field must be a real date'
64
+ }
65
+
66
+ export interface FormParams {
67
+ action?: FormAction
68
+ confirm?: true
69
+ crumb?: string
70
+ itemId?: string
71
+ }
72
+
73
+ /**
74
+ * Form POST for question pages
75
+ * (after Joi has converted value types)
76
+ */
77
+ export type FormPayload = FormParams & Partial<Record<string, FormValue>>
78
+
79
+ export type FormValue =
80
+ | Item['value']
81
+ | Item['value'][]
82
+ | UploadState
83
+ | RepeatListState
84
+ | undefined
85
+
86
+ export type FormState = Partial<Record<string, FormStateValue>>
87
+ export type FormStateValue = Exclude<FormValue, undefined> | null
88
+
89
+ export interface FormValidationResult<
90
+ ValueType extends FormPayload | FormSubmissionState
91
+ > {
92
+ value: ValueType
93
+ errors: FormSubmissionError[] | undefined
94
+ }
95
+
96
+ export interface FormContext {
97
+ /**
98
+ * Evaluation form state only (filtered by visited paths),
99
+ * with values formatted for condition evaluation using
100
+ * {@link FormComponent.getContextValueFromState}
101
+ */
102
+ evaluationState: FormState
103
+
104
+ /**
105
+ * Relevant form state only (filtered by visited paths)
106
+ */
107
+ relevantState: FormState
108
+
109
+ /**
110
+ * Relevant pages only (filtered by visited paths)
111
+ */
112
+ relevantPages: PageControllerClass[]
113
+
114
+ /**
115
+ * Form submission payload (single page)
116
+ */
117
+ payload: FormPayload
118
+
119
+ /**
120
+ * Form submission state (entire form)
121
+ */
122
+ state: FormSubmissionState
123
+
124
+ /**
125
+ * Validation errors (entire form)
126
+ */
127
+ errors?: FormSubmissionError[]
128
+
129
+ /**
130
+ * Visited paths evaluated from form state
131
+ */
132
+ paths: string[]
133
+
134
+ /**
135
+ * Preview URL direct access is allowed
136
+ */
137
+ isForceAccess: boolean
138
+
139
+ /**
140
+ * Miscellaneous extra data from event responses
141
+ */
142
+ data: object
143
+
144
+ pageDefMap: Map<string, Page>
145
+ listDefMap: Map<string, List>
146
+ componentDefMap: Map<string, ComponentDef>
147
+ pageMap: Map<string, PageControllerClass>
148
+ componentMap: Map<string, Component>
149
+ }
150
+
151
+ export type FormContextRequest = (
152
+ | {
153
+ method: 'get'
154
+ payload?: undefined
155
+ }
156
+ | {
157
+ method: 'post'
158
+ payload: FormPayload
159
+ }
160
+ | {
161
+ method: FormRequest['method']
162
+ payload?: object | undefined
163
+ }
164
+ ) &
165
+ Pick<FormRequest, 'app' | 'method' | 'params' | 'path' | 'query' | 'url'>
166
+
167
+ export interface UploadInitiateResponse {
168
+ uploadId: string
169
+ uploadUrl: string
170
+ statusUrl: string
171
+ }
172
+
173
+ export enum UploadStatus {
174
+ initiated = 'initiated',
175
+ pending = 'pending',
176
+ ready = 'ready'
177
+ }
178
+
179
+ export enum FileStatus {
180
+ complete = 'complete',
181
+ rejected = 'rejected',
182
+ pending = 'pending'
183
+ }
184
+
185
+ export type UploadState = FileState[]
186
+
187
+ export type FileUpload = {
188
+ fileId: string
189
+ filename: string
190
+ contentLength: number
191
+ } & (
192
+ | {
193
+ fileStatus: FileStatus.complete | FileStatus.rejected | FileStatus.pending
194
+ errorMessage?: string
195
+ }
196
+ | {
197
+ fileStatus: FileStatus.complete
198
+ errorMessage?: undefined
199
+ }
200
+ )
201
+
202
+ export interface FileUploadMetadata {
203
+ retrievalKey: string
204
+ }
205
+
206
+ export type UploadStatusResponse =
207
+ | {
208
+ uploadStatus: UploadStatus.initiated
209
+ metadata: FileUploadMetadata
210
+ form: { file?: undefined }
211
+ }
212
+ | {
213
+ uploadStatus: UploadStatus.pending | UploadStatus.ready
214
+ metadata: FileUploadMetadata
215
+ form: { file: FileUpload }
216
+ numberOfRejectedFiles?: number
217
+ }
218
+ | {
219
+ uploadStatus: UploadStatus.ready
220
+ metadata: FileUploadMetadata
221
+ form: { file: FileUpload }
222
+ numberOfRejectedFiles: 0
223
+ }
224
+
225
+ export type UploadStatusFileResponse = Exclude<
226
+ UploadStatusResponse,
227
+ { uploadStatus: UploadStatus.initiated }
228
+ >
229
+
230
+ export interface FileState {
231
+ uploadId: string
232
+ status: UploadStatusFileResponse
233
+ }
234
+
235
+ export interface TempFileState {
236
+ upload?: UploadInitiateResponse
237
+ files: UploadState
238
+ }
239
+
240
+ export interface RepeatItemState extends FormPayload {
241
+ itemId: string
242
+ }
243
+
244
+ export type RepeatListState = RepeatItemState[]
245
+
246
+ export interface CheckAnswers {
247
+ title?: ComponentText
248
+ summaryList: SummaryList
249
+ }
250
+
251
+ export interface SummaryList {
252
+ classes?: string
253
+ rows: SummaryListRow[]
254
+ }
255
+
256
+ export interface SummaryListRow {
257
+ key: ComponentText
258
+ value: ComponentText
259
+ actions?: { items: SummaryListAction[] }
260
+ }
261
+
262
+ export type SummaryListAction = ComponentText & {
263
+ href: string
264
+ visuallyHiddenText: string
265
+ }
266
+
267
+ export interface PageViewModelBase extends Partial<ViewContext> {
268
+ page: PageController
269
+ name?: string
270
+ pageTitle: string
271
+ sectionTitle?: string
272
+ showTitle: boolean
273
+ isStartPage: boolean
274
+ backLink?: BackLink
275
+ feedbackLink?: string
276
+ serviceUrl: string
277
+ phaseTag?: string
278
+ }
279
+
280
+ export interface ItemDeletePageViewModel extends PageViewModelBase {
281
+ context: FormContext
282
+ itemTitle: string
283
+ confirmation?: ComponentText
284
+ buttonConfirm: ComponentText
285
+ buttonCancel: ComponentText
286
+ }
287
+
288
+ export interface FormPageViewModel extends PageViewModelBase {
289
+ components: ComponentViewModel[]
290
+ context: FormContext
291
+ errors?: FormSubmissionError[]
292
+ hasMissingNotificationEmail?: boolean
293
+ }
294
+
295
+ export interface RepeaterSummaryPageViewModel extends PageViewModelBase {
296
+ context: FormContext
297
+ errors?: FormSubmissionError[]
298
+ checkAnswers: CheckAnswers[]
299
+ repeatTitle: string
300
+ }
301
+
302
+ export interface FeaturedFormPageViewModel extends FormPageViewModel {
303
+ formAction?: string
304
+ formComponent: ComponentViewModel
305
+ componentsBefore: ComponentViewModel[]
306
+ uploadId: string | undefined
307
+ proxyUrl: string | null
308
+ }
309
+
310
+ export type PageViewModel =
311
+ | PageViewModelBase
312
+ | ItemDeletePageViewModel
313
+ | FormPageViewModel
314
+ | RepeaterSummaryPageViewModel
315
+ | FeaturedFormPageViewModel