"
+ Examples:
+ | start | result | value |
+ | /query-param-form?country=Turkey | /query-param-form/start | Turkey |
+ | /query-param-form/start?country=Turkey | /query-param-form/start | Turkey |
+ | /query-param-form/start?country=not%20a%20country | /query-param-form/start | |
+ | /disabled-query-param-form/start?country=Turkey | /disabled-query-param-form/start | |
+
+ Scenario: Query param vs form state
+ When I navigate to the "query-param-form" form
+ And I enter "Turkey" for "Start"
+ And I continue
+ And I navigate to "/query-param-form/second-page?country=Afghanistan"
+ Then I am redirected to "/query-param-form/second-page"
+ And I see "You chose the option Turkey"
diff --git a/e2e/cypress/e2e/runner/redirect.feature b/e2e/cypress/e2e/runner/redirect.feature
new file mode 100644
index 0000000000..2fc0361bd2
--- /dev/null
+++ b/e2e/cypress/e2e/runner/redirect.feature
@@ -0,0 +1,18 @@
+Feature: Back link fallback
+ As a service team,
+ I want to redirect to another URL
+ So that part of the service may be completed by another form or url
+
+ Scenario: Redirects to another page
+ Given the form "redirects" exists
+ When I navigate to the "redirects" form
+ And I enter "Turkey" for "Start"
+ And I continue
+ Then I see "Cookies are files saved on your phone, tablet or computer when you visit a website."
+
+ Scenario: Continues to next page
+ Given the form "redirects" exists
+ When I navigate to the "redirects" form
+ And I enter "Thailand" for "Start"
+ And I continue
+ Then I see "Second page"
diff --git a/e2e/cypress/fixtures/backLinkFallback.json b/e2e/cypress/fixtures/backLinkFallback.json
new file mode 100644
index 0000000000..6a72aafd66
--- /dev/null
+++ b/e2e/cypress/fixtures/backLinkFallback.json
@@ -0,0 +1,53 @@
+{
+ "startPage": "/start",
+ "pages": [
+ {
+ "backLinkFallback": "/help/cookies",
+ "title": "start page",
+ "path": "/start",
+ "components": [],
+ "next": [
+ {
+ "path": "/second-page"
+ }
+ ]
+ },
+ {
+ "backLinkFallback": "/help/terms-and-conditions",
+ "title": "second page",
+ "path": "/second-page",
+ "components": [],
+ "next": [
+ {
+ "path": "/third-page"
+ }
+ ]
+ },
+ {
+ "title": "third page",
+ "path": "/third-page",
+ "components": [],
+ "next": [
+ {
+ "path": "/summary"
+ }
+ ]
+ },
+ {
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "title": "Summary",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [],
+ "sections": [],
+ "phaseBanner": {},
+ "fees": [],
+ "payApiKey": "",
+ "outputs": [],
+ "declaration": "All the answers you have provided are true to the best of your knowledge.
",
+ "version": 2,
+ "conditions": []
+}
diff --git a/e2e/cypress/fixtures/date.json b/e2e/cypress/fixtures/date.json
new file mode 100644
index 0000000000..57a62e4cbd
--- /dev/null
+++ b/e2e/cypress/fixtures/date.json
@@ -0,0 +1,52 @@
+{
+ "metadata": {},
+ "startPage": "/start",
+ "pages": [
+ {
+ "title": "Start",
+ "path": "/start",
+ "components": [
+ {
+ "type": "DatePartsField",
+ "name": "maxFiveDaysInFuture",
+ "title": "Enter a date at most 5 days in the future",
+ "schema": {},
+ "options": {
+ "maxDaysInFuture": 5
+ }
+ },
+ {
+ "type": "DatePartsField",
+ "name": "maxFiveDaysInPast",
+ "title": "Enter a date at most 5 days in the past",
+ "schema": {},
+ "options": {
+ "maxDaysInPast": 5
+ }
+ }
+ ],
+ "next": [{ "path": "/second-page" }]
+ },
+ {
+ "path": "/second-page",
+ "title": "Second page",
+ "components": [],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [],
+ "sections": [],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/e2e/cypress/fixtures/disabled-query-param-form.json b/e2e/cypress/fixtures/disabled-query-param-form.json
new file mode 100644
index 0000000000..2036d3b300
--- /dev/null
+++ b/e2e/cypress/fixtures/disabled-query-param-form.json
@@ -0,0 +1,60 @@
+{
+ "metadata": {},
+ "startPage": "/start",
+ "pages": [
+ {
+ "title": "Start",
+ "path": "/start",
+ "components": [
+ {
+ "name": "country",
+ "options": { "exposeToContext": true },
+ "type": "AutocompleteField",
+ "title": "Country",
+ "nameHasError": false,
+ "list": "SfkWjb",
+ "values": { "type": "listRef" }
+ }
+ ],
+ "next": [{ "path": "/second-page" }]
+ },
+ {
+ "path": "/second-page",
+ "title": "Second page",
+ "components": [
+ {
+ "name": "SFtcpL",
+ "options": {},
+ "type": "Html",
+ "content": "You chose the option {{country}}
"
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "Countries",
+ "name": "SfkWjb",
+ "type": "string",
+ "items": [
+ { "text": "Turkey", "value": "Turkey" },
+ { "text": "Thailand", "value": "Thailand" }
+ ]
+ }
+ ],
+ "sections": [],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/e2e/cypress/fixtures/fails-ocr.png b/e2e/cypress/fixtures/fails-ocr.png
new file mode 100644
index 0000000000..3c199a1326
Binary files /dev/null and b/e2e/cypress/fixtures/fails-ocr.png differ
diff --git a/e2e/cypress/fixtures/html-templating-example.json b/e2e/cypress/fixtures/html-templating-example.json
new file mode 100644
index 0000000000..5f99246051
--- /dev/null
+++ b/e2e/cypress/fixtures/html-templating-example.json
@@ -0,0 +1,63 @@
+{
+ "metadata": {},
+ "startPage": "/which-content-do-you-want-to-display",
+ "pages": [
+ {
+ "title": "Which content do you want to display?",
+ "path": "/which-content-do-you-want-to-display",
+ "components": [
+ {
+ "name": "contentToDisplay",
+ "options": { "exposeToContext": true },
+ "type": "RadiosField",
+ "title": "Content to display",
+ "list": "vYTQRu",
+ "nameHasError": false,
+ "values": { "type": "listRef" },
+ "schema": {}
+ }
+ ],
+ "next": [{ "path": "/second-page" }],
+ "section": "gcdSFb"
+ },
+ {
+ "path": "/second-page",
+ "title": "Dynamic page based on your answers: {{ gcdSFb.contentToDisplay }}",
+ "components": [
+ {
+ "name": "azSyOn",
+ "options": {},
+ "type": "Html",
+ "content": "This page demonstrates how templating works.
\nDepending on the answer you chose for the first question, you should see different content displayed below.
\nYou chosen the option: {{gcdSFb.contentToDisplay}}
\n{{additionalContexts.example[gcdSFb.contentToDisplay].additionalInfo | safe}}\nThe following list will have different items
\n\n{{additionalContexts.example[gcdSFb.contentToDisplay].listItems | safe }}\n
",
+ "schema": {}
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "Options",
+ "name": "vYTQRu",
+ "type": "string",
+ "items": [
+ { "text": "Answer 1", "value": "Answer 1" },
+ { "text": "Answer 2", "value": "Answer 2" }
+ ]
+ }
+ ],
+ "sections": [{ "name": "gcdSFb", "title": "section" }],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": { "allowSubmissionWithoutPayment": true, "maxAttempts": 3 }
+}
diff --git a/e2e/cypress/fixtures/image-quality-playback.json b/e2e/cypress/fixtures/image-quality-playback.json
new file mode 100644
index 0000000000..1b259656b0
--- /dev/null
+++ b/e2e/cypress/fixtures/image-quality-playback.json
@@ -0,0 +1,35 @@
+{
+ "metadata": {},
+ "startPage": "/upload-a-file",
+ "pages": [
+ {
+ "title": "Upload a file",
+ "path": "/upload-a-file",
+ "components": [
+ {
+ "name": "zRpydv",
+ "options": {},
+ "type": "FileUploadField",
+ "title": "File upload"
+ }
+ ],
+ "next": [{ "path": "/summary" }],
+ "controller": "UploadPageController"
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [],
+ "sections": [],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/e2e/cypress/fixtures/initialiseSession.json b/e2e/cypress/fixtures/initialiseSession.json
index 687b6b737b..fc30ffbfad 100644
--- a/e2e/cypress/fixtures/initialiseSession.json
+++ b/e2e/cypress/fixtures/initialiseSession.json
@@ -7,11 +7,31 @@
"components": [],
"next": [
{
- "path": "/summary"
+ "path": "/name"
}
],
"controller": "./pages/start.js"
},
+ {
+ "title": "What is your name?",
+ "path": "/name",
+ "components": [
+ {
+ "type": "TextField",
+ "name": "firstName",
+ "title": "First name",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/summary"
+ }
+ ]
+ },
{
"path": "/summary",
"controller": "./pages/summary.js",
@@ -20,14 +40,12 @@
"next": []
}
],
- "lists": [
- ],
+ "lists": [],
"sections": [],
"phaseBanner": {},
"fees": [],
"payApiKey": "",
- "outputs": [
- ],
+ "outputs": [],
"declaration": "All the answers you have provided are true to the best of your knowledge.
",
"version": 2,
"conditions": []
diff --git a/e2e/cypress/fixtures/notifyOutput.json b/e2e/cypress/fixtures/notifyOutput.json
new file mode 100644
index 0000000000..2775351c8b
--- /dev/null
+++ b/e2e/cypress/fixtures/notifyOutput.json
@@ -0,0 +1,105 @@
+{
+ "metadata": {},
+ "startPage": "/first-page",
+ "pages": [
+ {
+ "title": "First page",
+ "path": "/first-page",
+ "components": [
+ {
+ "name": "SWJtVi",
+ "options": {},
+ "type": "YesNoField",
+ "title": "Should item 1 be shown?"
+ },
+ {
+ "name": "dxWjPr",
+ "options": {},
+ "type": "YesNoField",
+ "title": "Should item 2 be shown?"
+ },
+ {
+ "name": "TZOHRn",
+ "options": {},
+ "type": "EmailAddressField",
+ "title": "Email address"
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "New list",
+ "name": "wVUZJW",
+ "type": "string",
+ "items": [
+ { "text": "Item 1", "value": "Item 1", "condition": "KAOicj" },
+ { "text": "Item 2", "value": "Item 2", "condition": "vzzqjG" },
+ { "text": "Item 3", "value": "Item 3" }
+ ]
+ }
+ ],
+ "sections": [],
+ "conditions": [
+ {
+ "displayName": "Item 1 should be shown",
+ "name": "KAOicj",
+ "value": {
+ "name": "Item 1 should be shown",
+ "conditions": [
+ {
+ "field": {
+ "name": "SWJtVi",
+ "type": "YesNoField",
+ "display": "Should item 1 be shown?"
+ },
+ "operator": "is",
+ "value": { "type": "Value", "value": "true", "display": "true" }
+ }
+ ]
+ }
+ },
+ {
+ "displayName": "Item 2 should be shown",
+ "name": "vzzqjG",
+ "value": {
+ "name": "Item 2 should be shown",
+ "conditions": [
+ {
+ "field": {
+ "name": "dxWjPr",
+ "type": "YesNoField",
+ "display": "Should item 2 be shown?"
+ },
+ "operator": "is",
+ "value": { "type": "Value", "value": "true", "display": "true" }
+ }
+ ]
+ }
+ }
+ ],
+ "fees": [],
+ "outputs": [
+ {
+ "name": "iykabp",
+ "title": "test",
+ "type": "notify",
+ "outputConfiguration": {
+ "personalisation": ["wVUZJW"],
+ "templateId": "test",
+ "apiKey": "test",
+ "emailField": "TZOHRn",
+ "addReferencesToPersonalisation": false
+ }
+ }
+ ],
+ "version": 2,
+ "skipSummary": false
+}
diff --git a/e2e/cypress/fixtures/passes.png b/e2e/cypress/fixtures/passes.png
new file mode 100644
index 0000000000..01a78c6373
Binary files /dev/null and b/e2e/cypress/fixtures/passes.png differ
diff --git a/e2e/cypress/fixtures/query-param-form.json b/e2e/cypress/fixtures/query-param-form.json
new file mode 100644
index 0000000000..66dbe1d980
--- /dev/null
+++ b/e2e/cypress/fixtures/query-param-form.json
@@ -0,0 +1,60 @@
+{
+ "metadata": {},
+ "startPage": "/start",
+ "pages": [
+ {
+ "title": "Start",
+ "path": "/start",
+ "components": [
+ {
+ "name": "country",
+ "options": { "exposeToContext": true, "allowPrePopulation": true },
+ "type": "AutocompleteField",
+ "title": "Country",
+ "nameHasError": false,
+ "list": "SfkWjb",
+ "values": { "type": "listRef" }
+ }
+ ],
+ "next": [{ "path": "/second-page" }]
+ },
+ {
+ "path": "/second-page",
+ "title": "Second page",
+ "components": [
+ {
+ "name": "SFtcpL",
+ "options": {},
+ "type": "Html",
+ "content": "You chose the option {{country}}
"
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "Countries",
+ "name": "SfkWjb",
+ "type": "string",
+ "items": [
+ { "text": "Turkey", "value": "Turkey" },
+ { "text": "Thailand", "value": "Thailand" }
+ ]
+ }
+ ],
+ "sections": [],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/e2e/cypress/fixtures/redirects.json b/e2e/cypress/fixtures/redirects.json
new file mode 100644
index 0000000000..a1a236080a
--- /dev/null
+++ b/e2e/cypress/fixtures/redirects.json
@@ -0,0 +1,87 @@
+{
+ "metadata": {},
+ "startPage": "/start",
+ "pages": [
+ {
+ "title": "Start",
+ "path": "/start",
+ "section": "beforeYouStart",
+ "components": [
+ {
+ "name": "country",
+ "type": "AutocompleteField",
+ "title": "Country",
+ "nameHasError": false,
+ "list": "SfkWjb"
+ }
+ ],
+ "next": [
+ {
+ "path": "/second-page"
+ },
+ {
+ "redirect": "http://localhost:3009/help/cookies",
+ "condition": "shouldRedirectToCookiesPage"
+ }
+ ]
+ },
+ {
+ "path": "/second-page",
+ "title": "Second page",
+ "components": [
+ {
+ "name": "SFtcpL",
+ "options": {},
+ "type": "Html",
+ "content": "You chose the option {{country}}
"
+ }
+ ],
+ "next": [
+ {
+ "path": "/summary"
+ }
+ ]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "Countries",
+ "name": "SfkWjb",
+ "type": "string",
+ "items": [
+ {
+ "text": "Turkey",
+ "value": "Turkey"
+ },
+ {
+ "text": "Thailand",
+ "value": "Thailand"
+ }
+ ]
+ }
+ ],
+ "sections": [
+ {
+ "title": "Before you start",
+ "name": "beforeYouStart"
+ }
+ ],
+ "conditions": [
+ {
+ "name": "shouldRedirectToCookiesPage",
+ "value": "beforeYouStart.country == 'Turkey'"
+ }
+ ],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/e2e/cypress/support/commands.js b/e2e/cypress/support/commands.js
index 81f1958040..e37e344820 100644
--- a/e2e/cypress/support/commands.js
+++ b/e2e/cypress/support/commands.js
@@ -25,3 +25,4 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "@testing-library/cypress/add-commands";
+import "cypress-file-upload";
diff --git a/e2e/cypress/support/step_definitions/designer/i_am_viewing_the_designer.js b/e2e/cypress/support/step_definitions/designer/i_am_viewing_the_designer.js
index c523633220..c9dacdef8d 100644
--- a/e2e/cypress/support/step_definitions/designer/i_am_viewing_the_designer.js
+++ b/e2e/cypress/support/step_definitions/designer/i_am_viewing_the_designer.js
@@ -1,5 +1,5 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
Given("I am viewing the designer at {string}", (path) => {
- cy.visit(`${Cypress.env.DESIGNER_PATH}${path}`);
+ cy.visit(`${Cypress.env("DESIGNER_URL")}${path}`);
});
diff --git a/e2e/cypress/support/step_definitions/runner/i_am_redirected_to_string.js b/e2e/cypress/support/step_definitions/runner/i_am_redirected_to_string.js
new file mode 100644
index 0000000000..b23dbd96af
--- /dev/null
+++ b/e2e/cypress/support/step_definitions/runner/i_am_redirected_to_string.js
@@ -0,0 +1,5 @@
+import { Then } from "@badeball/cypress-cucumber-preprocessor";
+
+Then("I am redirected to {string}", (url) => {
+ cy.url().should("contain", url);
+});
diff --git a/e2e/cypress/support/step_definitions/runner/i_navigate_to_string.js b/e2e/cypress/support/step_definitions/runner/i_navigate_to_string.js
new file mode 100644
index 0000000000..59940d225e
--- /dev/null
+++ b/e2e/cypress/support/step_definitions/runner/i_navigate_to_string.js
@@ -0,0 +1,7 @@
+import { When } from "@badeball/cypress-cucumber-preprocessor";
+
+When("I navigate to {string}", (url) => {
+ cy.visit(`${Cypress.env("RUNNER_URL")}${url}`, {
+ failOnStatusCode: false,
+ });
+});
diff --git a/e2e/cypress/support/step_definitions/runner/i_navigate_to_the_string_form.js b/e2e/cypress/support/step_definitions/runner/i_navigate_to_the_string_form.js
index 77197d5ea4..2531b351a7 100644
--- a/e2e/cypress/support/step_definitions/runner/i_navigate_to_the_string_form.js
+++ b/e2e/cypress/support/step_definitions/runner/i_navigate_to_the_string_form.js
@@ -1,5 +1,7 @@
import { Given, When } from "@badeball/cypress-cucumber-preprocessor";
Given("I navigate to the {string} form", (formName) => {
- cy.visit(`${Cypress.env("RUNNER_URL")}/${formName}`);
+ cy.visit(`${Cypress.env("RUNNER_URL")}/${formName}`, {
+ failOnStatusCode: false,
+ });
});
diff --git a/e2e/cypress/support/step_definitions/runner/i_see_the_string_page.js b/e2e/cypress/support/step_definitions/runner/i_see_the_string_page.js
new file mode 100644
index 0000000000..3552783965
--- /dev/null
+++ b/e2e/cypress/support/step_definitions/runner/i_see_the_string_page.js
@@ -0,0 +1,10 @@
+import { Then } from "@badeball/cypress-cucumber-preprocessor";
+
+const pagesMap = {
+ upload: "Upload a file",
+ imageQualityPlayback: "Check your image",
+ summary: "Summary",
+};
+Then("I see the {string} page", (page) => {
+ cy.findByText(pagesMap[page]);
+});
diff --git a/e2e/cypress/support/step_definitions/runner/i_upload_a_file_that_string.js b/e2e/cypress/support/step_definitions/runner/i_upload_a_file_that_string.js
new file mode 100644
index 0000000000..cb47cebce6
--- /dev/null
+++ b/e2e/cypress/support/step_definitions/runner/i_upload_a_file_that_string.js
@@ -0,0 +1,6 @@
+import { When } from "@badeball/cypress-cucumber-preprocessor";
+
+When("I upload a file that {string}", (result) => {
+ cy.get("input[type=file]").attachFile(`${result}.png`);
+ cy.findByRole("button", { name: /continue/i }).click();
+});
diff --git a/e2e/cypress/support/step_definitions/runner/the_field_string_contains_string.js b/e2e/cypress/support/step_definitions/runner/the_field_string_contains_string.js
new file mode 100644
index 0000000000..c29595968e
--- /dev/null
+++ b/e2e/cypress/support/step_definitions/runner/the_field_string_contains_string.js
@@ -0,0 +1,9 @@
+import { Then } from "@badeball/cypress-cucumber-preprocessor";
+
+Then("the field {string} contains {string}", (label, value) => {
+ cy.findByLabelText(label)
+ .invoke("val")
+ .then((val) => {
+ expect(val).to.equal(value);
+ });
+});
diff --git a/e2e/cypress/support/step_definitions/runner/the_form_string_exists.js b/e2e/cypress/support/step_definitions/runner/the_form_string_exists.js
index e79df476d5..1a7e87be34 100644
--- a/e2e/cypress/support/step_definitions/runner/the_form_string_exists.js
+++ b/e2e/cypress/support/step_definitions/runner/the_form_string_exists.js
@@ -8,9 +8,10 @@ Given("the form {string} exists", (formName) => {
id: formName,
configuration: json,
};
- console.log(requestBody);
cy.request("POST", url, requestBody);
});
- cy.visit(`${Cypress.env("RUNNER_URL")}/${formName}`);
+ cy.visit(`${Cypress.env("RUNNER_URL")}/${formName}`, {
+ failOnStatusCode: false,
+ });
});
diff --git a/e2e/package.json b/e2e/package.json
index da5b4be619..af5c068507 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -9,7 +9,7 @@
"@testing-library/dom": "^8.17.1",
"@testing-library/user-event": "^14.4.3",
"cypress": "^10.9.0",
- "cypress-file-upload": "^5.0.7",
+ "cypress-file-upload": "^5.0.8",
"eslint-plugin-json": "^3.1.0",
"nanoid": "^3.1.23",
"prettier": "2.3.0"
diff --git a/model/babel.config.json b/model/babel.config.json
index 0e26140039..1bb07c2c92 100644
--- a/model/babel.config.json
+++ b/model/babel.config.json
@@ -14,8 +14,8 @@
],
"plugins": [
"@babel/plugin-proposal-export-default-from",
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-private-methods",
+ "@babel/plugin-transform-class-properties",
+ "@babel/plugin-transform-private-methods",
"@babel/plugin-transform-runtime"
],
"sourceMaps": true
@@ -34,8 +34,8 @@
],
"plugins": [
"@babel/plugin-proposal-export-default-from",
- "@babel/plugin-proposal-class-properties",
- "@babel/plugin-proposal-private-methods",
+ "@babel/plugin-transform-class-properties",
+ "@babel/plugin-transform-private-methods",
"@babel/plugin-transform-runtime"
],
"sourceMaps": true
diff --git a/model/package.json b/model/package.json
index 55aa7cdeed..25063cd596 100644
--- a/model/package.json
+++ b/model/package.json
@@ -28,27 +28,25 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
- "@babel/runtime": "^7.17.2",
+ "@babel/runtime": "^7.23.3",
"boom": "7.3.0",
"btoa": "^1.2.1",
"govuk-frontend": "^4.0.1",
"joi": "17.2.1"
},
"devDependencies": {
- "@babel/cli": "^7.10.5",
- "@babel/core": "^7.11.1",
- "@babel/eslint-parser": "^7.17.0",
- "@babel/eslint-plugin": "^7.16.5",
- "@babel/plugin-proposal-class-properties": "7.16.7",
- "@babel/plugin-proposal-export-default-from": "^7.16.7",
- "@babel/plugin-proposal-private-methods": "^7.16.11",
- "@babel/plugin-transform-runtime": "^7.17.0",
- "@babel/preset-env": "^7.16.11",
- "@babel/preset-typescript": "^7.16.7",
+ "@babel/cli": "^7.23.3",
+ "@babel/core": "^7.23.3",
+ "@babel/eslint-parser": "^7.21.3",
+ "@babel/eslint-plugin": "^7.22.10",
+ "@babel/plugin-proposal-export-default-from": "^7.23.3",
+ "@babel/plugin-transform-runtime": "^7.23.3",
+ "@babel/preset-env": "^7.23.3",
+ "@babel/preset-typescript": "^7.23.3",
"@types/jest": "^27.4.1",
"@xgovformbuilder/lab-babel": "2.1.2",
"babel-eslint": "^10.1.0",
- "babel-jest": "^27.5.1",
+ "babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"depth-first": "^4.0.0",
"eslint": "^8.10.0",
@@ -58,10 +56,10 @@
"hmpo-components": "5.2.1",
"jest": "^29.2.0",
"nanoid": "^3.3.4",
- "nunjucks": "3.2.1",
+ "nunjucks": "^3.2.3",
"path": "0.12.7",
- "ts-jest": "^26.4.4",
- "typescript": "^4.1.3",
+ "ts-jest": "^29.1.1",
+ "typescript": "4.9.5",
"wreck": "14.2.0"
}
}
diff --git a/model/src/components/component-types.ts b/model/src/components/component-types.ts
index d60511a5bc..fd4c1a7406 100644
--- a/model/src/components/component-types.ts
+++ b/model/src/components/component-types.ts
@@ -86,7 +86,7 @@ export const ComponentTypes: ComponentDef[] = [
name: "SelectField",
type: "SelectField",
title: "Select field",
- subType: "field",
+ subType: "listField",
options: {},
schema: {},
list: "",
@@ -95,7 +95,7 @@ export const ComponentTypes: ComponentDef[] = [
name: "AutocompleteField",
type: "AutocompleteField",
title: "Autocomplete field",
- subType: "field",
+ subType: "listField",
options: {},
schema: {},
list: "",
@@ -104,7 +104,7 @@ export const ComponentTypes: ComponentDef[] = [
name: "RadiosField",
type: "RadiosField",
title: "Radios field",
- subType: "field",
+ subType: "listField",
options: {},
schema: {},
list: "",
@@ -113,7 +113,7 @@ export const ComponentTypes: ComponentDef[] = [
name: "CheckboxesField",
type: "CheckboxesField",
title: "Checkboxes field",
- subType: "field",
+ subType: "listField",
options: {},
schema: {},
list: "",
diff --git a/model/src/components/types.ts b/model/src/components/types.ts
index a4a177bc1a..cb9925b0ff 100644
--- a/model/src/components/types.ts
+++ b/model/src/components/types.ts
@@ -23,6 +23,7 @@ export enum ComponentTypeEnum {
Details = "Details",
FlashCard = "FlashCard",
List = "List",
+ ContextComponent = "ContextComponent",
}
export type ComponentType =
@@ -50,7 +51,8 @@ export type ComponentType =
| "Details"
| "FlashCard"
| "List"
- | "WebsiteField";
+ | "WebsiteField"
+ | "ContextComponent";
export type ComponentSubType = "field" | "content";
@@ -80,6 +82,9 @@ interface TextFieldBase {
classes?: string;
allow?: string;
autocomplete?: string;
+ exposeToContext?: boolean;
+ disableChangingFromSummary?: boolean;
+ customValidationMessages?: Record;
};
schema: {
max?: number;
@@ -99,6 +104,9 @@ interface NumberFieldBase {
options: {
prefix?: string;
suffix?: string;
+ exposeToContext?: boolean;
+ disableChangingFromSummary?: boolean;
+ customValidationMessages?: Record;
};
schema: {
min?: number;
@@ -108,7 +116,7 @@ interface NumberFieldBase {
}
interface ListFieldBase {
- subType?: "field" | "content";
+ subType?: "listField" | "content";
type: string;
name: string;
title: string;
@@ -119,6 +127,11 @@ interface ListFieldBase {
optionalText?: boolean;
classes?: string;
bold?: boolean;
+ exposeToContext?: boolean;
+ allowPrePopulation?: boolean;
+ allowPrePopulationOverwrite?: boolean;
+ disableChangingFromSummary?: boolean;
+ customValidationMessages?: Record;
};
list: string;
schema: {};
@@ -146,6 +159,9 @@ interface DateFieldBase {
optionalText?: boolean;
maxDaysInFuture?: number;
maxDaysInPast?: number;
+ exposeToContext?: boolean;
+ disableChangingFromSummary?: boolean;
+ customValidationMessages?: Record;
};
schema: {};
}
@@ -181,7 +197,7 @@ export interface TelephoneNumberFieldComponent extends TextFieldBase {
type: "TelephoneNumberField";
options: TextFieldBase["options"] & {
customValidationMessage?: string;
- requiredFieldValidationMessage?: string;
+ isInternational?: boolean;
};
}
@@ -213,6 +229,9 @@ export interface FileUploadFieldComponent {
hideTitle?: boolean;
multiple?: boolean;
classes?: string;
+ exposeToContext?: boolean;
+ imageQualityPlayback?: boolean;
+ disableChangingFromSummary?: boolean;
};
schema: {};
}
@@ -270,10 +289,12 @@ export interface ListComponent extends ListFieldBase {
export interface AutocompleteFieldComponent extends ListFieldBase {
type: "AutocompleteField";
+ subType?: "listField";
}
export interface CheckboxesFieldComponent extends ListFieldBase {
type: "CheckboxesField";
+ subType?: "listField";
}
export interface FlashCardComponent extends ListFieldBase {
@@ -282,11 +303,19 @@ export interface FlashCardComponent extends ListFieldBase {
export interface RadiosFieldComponent extends ListFieldBase {
type: "RadiosField";
+ subType?: "listField";
}
export interface SelectFieldComponent extends ListFieldBase {
type: "SelectField";
options: ListFieldBase["options"] & { autocomplete?: string };
+ subType?: "listField";
+}
+
+export interface ContextComponent extends ListFieldBase {
+ type: "ContextComponent";
+ options: ListFieldBase["options"];
+ section?: string;
}
export type ComponentDef =
@@ -314,7 +343,8 @@ export type ComponentDef =
| TimeFieldComponent
| UkAddressFieldComponent
| YesNoFieldComponent
- | WebsiteFieldComponent;
+ | WebsiteFieldComponent
+ | ContextComponent;
// Components that render inputs.
export type InputFieldsComponentsDef =
@@ -338,7 +368,9 @@ export type ContentComponentsDef =
| ParaComponent
| DetailsComponent
| HtmlComponent
- | InsetTextComponent;
+ | InsetTextComponent
+ | ListComponent
+ | FlashCardComponent;
// Components that render Lists
export type ListComponentsDef =
diff --git a/model/src/data-model/types.ts b/model/src/data-model/types.ts
index 3485b9d75f..27bb4f00e4 100644
--- a/model/src/data-model/types.ts
+++ b/model/src/data-model/types.ts
@@ -14,7 +14,7 @@ export interface Page {
path: string;
controller: string;
components?: ComponentDef[];
- section: string; // the section ID
+ section?: string; // the section ID
next?: { path: string; condition?: string }[];
}
@@ -35,6 +35,7 @@ export interface RepeatingFieldPage extends Page {
export interface Section {
name: string;
title: string;
+ hideTitle: boolean;
}
export interface Item {
@@ -82,15 +83,21 @@ export type NotifyOutputConfiguration = {
templateId: string;
emailField: string;
personalisation: string[];
+ personalisationFieldCustomisation?: {
+ [personalisationName: string]: string[];
+ };
addReferencesToPersonalisation?: boolean;
emailReplyToIdConfiguration?: {
emailReplyToId: string;
condition?: string | undefined;
}[];
+ escapeURLs?: boolean;
};
export type WebhookOutputConfiguration = {
url: string;
+ sendAdditionalPayMetadata?: boolean;
+ allowRetry?: boolean;
};
export type OutputConfiguration =
@@ -114,8 +121,17 @@ export type ConfirmationPage = {
components: ComponentDef[];
};
+export type PaymentSkippedWarningPage = {
+ customText: {
+ title: string;
+ caption: string;
+ body: string;
+ };
+};
+
export type SpecialPages = {
confirmationPage?: ConfirmationPage;
+ paymentSkippedWarningPage?: PaymentSkippedWarningPage;
};
export function isMultipleApiKey(
@@ -133,6 +149,23 @@ export type Fee = {
prefix?: string;
};
+export type AdditionalReportingColumn = {
+ columnName: string;
+ fieldPath?: string;
+ staticValue?: string;
+};
+
+export type FeeOptions = {
+ paymentReferenceFormat?: string;
+ payReturnUrl?: string;
+ allowSubmissionWithoutPayment: boolean;
+ maxAttempts: number;
+ customPayErrorMessage?: string;
+ showPaymentSkippedWarningPage: boolean;
+ additionalReportingColumns?: AdditionalReportingColumn[];
+ payApiKey?: string | MultipleApiKeys | undefined;
+};
+
/**
* `FormDefinition` is a typescript representation of `Schema`
*/
@@ -153,5 +186,6 @@ export type FormDefinition = {
payApiKey?: string | MultipleApiKeys | undefined;
specialPages?: SpecialPages;
paymentReferenceFormat?: string;
+ feeOptions: FeeOptions;
authCheck?: boolean | undefined;
};
diff --git a/model/src/schema/__tests__/schema.test.ts b/model/src/schema/__tests__/schema.test.ts
index 332157c14a..1d54cfb577 100644
--- a/model/src/schema/__tests__/schema.test.ts
+++ b/model/src/schema/__tests__/schema.test.ts
@@ -2,29 +2,118 @@
import { Schema } from "../schema";
-describe("Form schema", () => {
- test("allows feedback URL to be an empty string when feedbackForm is false", () => {
- const goodConfiguration = {
- metadata: {},
- startPage: "/first-page",
- pages: [],
- lists: [],
- sections: [],
- conditions: [],
- fees: [],
- outputs: [],
- version: 2,
- skipSummary: false,
- name: "Schema fix 3",
- feedback: { feedbackForm: false, url: "" },
- phaseBanner: {},
- authCheck: false,
+const baseConfiguration = {
+ metadata: {},
+ startPage: "/first-page",
+ pages: [],
+ lists: [],
+ sections: [],
+ conditions: [],
+ fees: [],
+ outputs: [],
+ version: 2,
+ skipSummary: false,
+ phaseBanner: {},
+};
+
+test("allows feedback URL to be an empty string when feedbackForm is false", () => {
+ const goodConfiguration = {
+ ...baseConfiguration,
+ feedback: {
+ feedbackForm: false,
+ url: "",
+ },
+ name: "Schema fix 3",
+ authCheck: false,
+ };
+
+ const { value, error } = Schema.validate(goodConfiguration, {
+ abortEarly: false,
+ });
+
+ expect(error).toEqual(undefined);
+});
+
+describe("payment configuration", () => {
+ test("top level payment configurations (payApiKey, paymentReferenceFormat, payReturnUrl) are valid", () => {
+ const configuration = {
+ ...baseConfiguration,
+ paymentReferenceFormat: "EGGS-",
};
- const { value, error } = Schema.validate(goodConfiguration, {
+ const { error } = Schema.validate(configuration, {
abortEarly: false,
});
expect(error).toEqual(undefined);
});
+
+ test("feeOptions object creates itself from top level configurations if present", () => {
+ const configuration = {
+ ...baseConfiguration,
+ paymentReferenceFormat: "EGGS-",
+ payApiKey: "ab-cd",
+ };
+
+ const { value } = Schema.validate(configuration, {
+ abortEarly: false,
+ });
+
+ expect(value.paymentReferenceFormat).toEqual("EGGS-");
+ expect(value.payApiKey).toEqual("ab-cd");
+
+ expect(value.feeOptions).toEqual({
+ paymentReferenceFormat: "EGGS-",
+ payApiKey: "ab-cd",
+ });
+ });
+
+ test("values can be configured via feeOptions", () => {
+ const configuration = {
+ ...baseConfiguration,
+ feeOptions: {
+ allowSubmissionWithoutPayment: false,
+ maxAttempts: 10,
+ paymentReferenceFormat: "EGGS-",
+ payReturnUrl: "https://my.egg.service.scramble",
+ },
+ };
+
+ const { value } = Schema.validate(configuration, {
+ abortEarly: false,
+ });
+
+ expect(value.feeOptions).toEqual({
+ allowSubmissionWithoutPayment: false,
+ maxAttempts: 10,
+ paymentReferenceFormat: "EGGS-",
+ payReturnUrl: "https://my.egg.service.scramble",
+ showPaymentSkippedWarningPage: false,
+ });
+ });
+
+ test("feeOptions are not overwritten by top level configuration", () => {
+ const configuration = {
+ ...baseConfiguration,
+ paymentReferenceFormat: "FRIED-",
+ feeOptions: {
+ allowSubmissionWithoutPayment: true,
+ maxAttempts: 3,
+ paymentReferenceFormat: "EGGS-",
+ payReturnUrl: "https://my.egg.service.scramble",
+ },
+ };
+
+ const { value } = Schema.validate(configuration, {
+ abortEarly: false,
+ });
+
+ expect(value.feeOptions).toEqual({
+ allowSubmissionWithoutPayment: true,
+ maxAttempts: 3,
+ paymentReferenceFormat: "EGGS-",
+ payReturnUrl: "https://my.egg.service.scramble",
+ showPaymentSkippedWarningPage: false,
+ });
+ });
});
diff --git a/model/src/schema/schema.ts b/model/src/schema/schema.ts
index af104fc041..c532f3001b 100644
--- a/model/src/schema/schema.ts
+++ b/model/src/schema/schema.ts
@@ -8,6 +8,7 @@ export const CURRENT_VERSION = 2;
const sectionsSchema = joi.object().keys({
name: joi.string().required(),
title: joi.string().required(),
+ hideTitle: joi.boolean().default(false),
});
const conditionFieldSchema = joi.object().keys({
@@ -95,8 +96,13 @@ export const componentSchema = joi
.unknown(true);
const nextSchema = joi.object().keys({
- path: joi.string().required(),
+ path: joi.string().when(joi.ref("redirect"), {
+ is: joi.exist(),
+ then: joi.string().optional(),
+ otherwise: joi.string().required(),
+ }),
condition: joi.string().allow("").optional(),
+ redirect: joi.string().optional(),
});
/**
@@ -112,6 +118,22 @@ const pageSchema = joi.object().keys({
next: joi.array().items(nextSchema),
repeatField: joi.string().optional(),
options: joi.object().optional(),
+ backLinkFallback: joi.string().optional(),
+});
+
+const startNavigationLinkSchema = joi.object().keys({
+ href: joi.string().required(),
+ labelText: joi.string().required(),
+});
+
+const multiStartPageSchema = pageSchema.keys({
+ controller: joi.string().valid("MultiStartPageController"),
+ showContinueButton: joi.boolean().default(false),
+ continueButtonText: joi.string().optional(),
+ startPageNavigation: joi.object().keys({
+ next: startNavigationLinkSchema.optional(),
+ previous: startNavigationLinkSchema.optional(),
+ }),
});
const toggleableString = joi.alternatives().try(joi.boolean(), joi.string());
@@ -131,8 +153,17 @@ const confirmationPageSchema = joi.object({
components: joi.array().items(componentSchema),
});
+const paymentSkippedWarningPage = joi.object({
+ customText: joi.object({
+ title: joi.string().default("Pay for your application").optional(),
+ caption: joi.string().default("Payment").optional(),
+ body: joi.string().default("").optional(),
+ }),
+});
+
const specialPagesSchema = joi.object().keys({
- confirmationPage: confirmationPageSchema,
+ confirmationPage: confirmationPageSchema.optional(),
+ paymentSkippedWarningPage: paymentSkippedWarningPage.optional(),
});
const listItemSchema = joi.object().keys({
@@ -170,6 +201,7 @@ const feeSchema = joi.object().keys({
const multiApiKeySchema = joi.object({
test: joi.string().optional(),
+ smoke: joi.string().optional(),
production: joi.string().optional(),
});
@@ -183,8 +215,13 @@ const notifySchema = joi.object().keys({
templateId: joi.string(),
emailField: joi.string(),
personalisation: joi.array().items(joi.string()),
+ personalisationFieldCustomisation: joi
+ .object()
+ .pattern(/./, joi.array().items(joi.string()))
+ .optional(),
addReferencesToPersonalisation: joi.boolean().optional(),
emailReplyToIdConfiguration: joi.array().items(replyToConfigurationSchema),
+ escapeURLs: joi.boolean().default(false),
});
const emailSchema = joi.object().keys({
@@ -193,6 +230,8 @@ const emailSchema = joi.object().keys({
const webhookSchema = joi.object().keys({
url: joi.string(),
+ sendAdditionalPayMetadata: joi.boolean().optional().default(false),
+ allowRetry: joi.boolean().default(true),
});
const outputSchema = joi.object().keys({
@@ -224,6 +263,38 @@ const phaseBannerSchema = joi.object().keys({
phase: joi.string().valid("alpha", "Beta"),
});
+const feeOptionSchema = joi
+ .object()
+ .keys({
+ payApiKey: [joi.string().allow("").optional(), multiApiKeySchema],
+ paymentReferenceFormat: [joi.string().optional()],
+ payReturnUrl: joi.string().optional(),
+ allowSubmissionWithoutPayment: joi.boolean().optional().default(true),
+ maxAttempts: joi.number().optional().default(3),
+ customPayErrorMessage: joi.string().optional(),
+ showPaymentSkippedWarningPage: joi.when("allowSubmissionWithoutPayment", {
+ is: true,
+ then: joi.boolean().valid(true, false).default(false),
+ otherwise: joi.boolean().valid(false).default(false),
+ }),
+ additionalReportingColumns: joi
+ .array()
+ .items(
+ joi.object({
+ columnName: joi.string().required(),
+ fieldPath: joi.string().optional(),
+ staticValue: joi.string().optional(),
+ })
+ )
+ .optional(),
+ })
+ .default(({ payApiKey, paymentReferenceFormat }) => {
+ return {
+ ...(payApiKey && { payApiKey }),
+ ...(paymentReferenceFormat && { paymentReferenceFormat }),
+ };
+ });
+
export const Schema = joi
.object()
.required()
@@ -231,7 +302,11 @@ export const Schema = joi
name: localisedString.optional(),
feedback: feedbackSchema,
startPage: joi.string().required(),
- pages: joi.array().required().items(pageSchema).unique("path"),
+ pages: joi
+ .array()
+ .required()
+ .items(joi.alternatives().try(pageSchema, multiStartPageSchema))
+ .unique("path"),
sections: joi.array().items(sectionsSchema).unique("name").required(),
conditions: joi.array().items(conditionsSchema).unique("name"),
lists: joi.array().items(listSchema).unique("name"),
@@ -245,6 +320,7 @@ export const Schema = joi
version: joi.number().default(CURRENT_VERSION),
phaseBanner: phaseBannerSchema,
specialPages: specialPagesSchema.optional(),
+ feeOptions: feeOptionSchema,
authCheck: joi.boolean().default(false).optional(),
});
@@ -255,4 +331,7 @@ export const Schema = joi
* options as 'values' rather than referencing a data list
* 2 - Reverse v1. Values populating radio, checkboxes, select, autocomplete are defined in Lists only.
* TODO:- merge fees and paymentReferenceFormat
+ * 2 - 2023-05-04 `feeOptions` has been introduced. paymentReferenceFormat and payApiKey can be configured in top level or feeOptions. feeOptions will take precedent.
+ * if feeOptions are empty, it will pull values from the top level keys.
+ * WARN: Fee/GOV.UK pay configurations (apart from fees) should no longer be stored in the top level, always within feeOptions.
**/
diff --git a/model/src/utils/helpers.ts b/model/src/utils/helpers.ts
index 71f70ae246..e4ba383418 100644
--- a/model/src/utils/helpers.ts
+++ b/model/src/utils/helpers.ts
@@ -19,7 +19,7 @@ export const clone = (obj: T & { clone?: () => T }): T => {
return obj;
};
-export function filter>(
+export function filter(
obj: T,
predicate: (value: any) => boolean
): Partial {
diff --git a/package.json b/package.json
index c86453f37e..5ea7f5b1e6 100644
--- a/package.json
+++ b/package.json
@@ -7,18 +7,20 @@
"license": "MIT",
"private": true,
"engines": {
- "node": ">=16"
+ "node": ">=18"
},
"workspaces": [
"model",
"runner",
"designer",
- "e2e"
+ "e2e",
+ "queue-model",
+ "submitter"
],
"scripts": {
"setup": "yarn && yarn build",
"build": "yarn workspaces foreach run build",
- "build:dependencies": "yarn model build",
+ "build:dependencies": "yarn model build && yarn queue-model build",
"lint": "yarn workspaces foreach run lint",
"test": "yarn workspaces foreach run test",
"fix-lint": "yarn workspaces foreach run fix-lint",
@@ -27,6 +29,8 @@
"designer": "yarn workspace @xgovformbuilder/designer",
"model": "yarn workspace @xgovformbuilder/model",
"e2e": "yarn workspace e2e",
+ "queue-model": "yarn workspace @xgovformbuilder/queue-model",
+ "submitter": "yarn workspace @xgovformbuilder/submitter",
"test-cov": "yarn workspaces foreach run test-cov",
"runner:start": "yarn workspace @xgovformbuilder/runner start",
"type-check": "yarn workspaces foreach run tsc --noEmit",
@@ -34,16 +38,15 @@
"generate-architecture-diagrams": "concurrently 'npx arkit -c ./docs/designer/arkit.json' 'npx arkit -c ./docs/model/arkit.json' 'npx arkit -c ./docs/runner/arkit.json'"
},
"devDependencies": {
- "@babel/cli": "^7.10.5",
- "@babel/core": "^7.11.1",
- "@babel/eslint-parser": "^7.11.3",
- "@babel/eslint-plugin": "^7.11.3",
- "@babel/preset-env": "^7.11.0",
- "@babel/preset-typescript": "^7.12.7",
+ "@babel/cli": "^7.23.3",
+ "@babel/core": "^7.23.3",
+ "@babel/eslint-parser": "^7.23.3",
+ "@babel/eslint-plugin": "^7.22.10",
+ "@babel/preset-env": "^7.23.3",
+ "@babel/preset-typescript": "^7.23.3",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
- "babel-eslint": "^11.0.0-beta.2",
- "concurrently": "^5.3.0",
+ "concurrently": "^8.0.0",
"eslint": "^7.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-alias": "^1.1.2",
@@ -57,15 +60,18 @@
"lint-staged": "^10.4.2",
"magic-string": "^0.25.7",
"prettier": "2.1.2",
+ "prisma": "^5.1.1",
"typedoc": "~0.23.17",
- "typescript": "^4.1.3"
+ "typescript": "4.9.5"
},
"dependencies": {
- "@babel/runtime": "^7.11.2",
- "govuk-frontend": "^4.3.1",
+ "@babel/runtime": "^7.21.0",
+ "govuk-frontend": "^4.8.0",
"hmpo-components": "^5.2.1"
},
"resolutions": {
+ "@babel/core": "^7.23.3",
+ "@babel/traverse": "^7.23.3",
"braces": "2.3.1",
"pathval": "1.1.1",
"y18n": "4.0.1",
@@ -83,7 +89,12 @@
"minimist": "~1.2.6",
"json-schema": "0.4.0",
"glob-parent": "5.1.2",
- "follow-redirects": "~1.14.1"
+ "follow-redirects": "~1.15.4",
+ "@xmldom/xmldom": "0.8.6",
+ "@cypress/request": "3.x.x",
+ "@adobe/css-tools": "4.3.1",
+ "kind-of": "6.0.3",
+ "semver": "7.5.2"
},
"husky": {
"hooks": {
diff --git a/queue-model/babel.config.json b/queue-model/babel.config.json
new file mode 100644
index 0000000000..a5248bb333
--- /dev/null
+++ b/queue-model/babel.config.json
@@ -0,0 +1,18 @@
+{
+ "env": {
+ "node": {
+ "presets": [
+ "@babel/typescript",
+ [
+ "@babel/preset-env",
+ {
+ "targets": {
+ "node": "16"
+ }
+ }
+ ]
+ ],
+ "sourceMaps": true
+ }
+ }
+}
diff --git a/queue-model/migrations/20230913152003_init/migration.sql b/queue-model/migrations/20230913152003_init/migration.sql
new file mode 100644
index 0000000000..44c4b74854
--- /dev/null
+++ b/queue-model/migrations/20230913152003_init/migration.sql
@@ -0,0 +1,14 @@
+-- CreateTable
+CREATE TABLE `Submission` (
+ `id` INTEGER NOT NULL AUTO_INCREMENT,
+ `webhook_url` VARCHAR(191) NULL,
+ `created_at` DATETIME(3) NOT NULL,
+ `updated_at` DATETIME(3) NOT NULL,
+ `data` VARCHAR(8192) NOT NULL,
+ `error` VARCHAR(191) NULL,
+ `return_reference` VARCHAR(191) NULL,
+ `complete` BOOLEAN NOT NULL,
+ `retry_counter` INTEGER NOT NULL,
+
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/queue-model/migrations/20230915145048_add_defaults/migration.sql b/queue-model/migrations/20230915145048_add_defaults/migration.sql
new file mode 100644
index 0000000000..578f37b1f5
--- /dev/null
+++ b/queue-model/migrations/20230915145048_add_defaults/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+ALTER TABLE `Submission` MODIFY `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ MODIFY `updated_at` DATETIME(3) NULL,
+ MODIFY `complete` BOOLEAN NOT NULL DEFAULT false;
diff --git a/queue-model/migrations/20230919102214_use_db_text/migration.sql b/queue-model/migrations/20230919102214_use_db_text/migration.sql
new file mode 100644
index 0000000000..a604757502
--- /dev/null
+++ b/queue-model/migrations/20230919102214_use_db_text/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE `Submission` MODIFY `data` TEXT NULL;
diff --git a/queue-model/migrations/20231107195736_add_allow_retry/migration.sql b/queue-model/migrations/20231107195736_add_allow_retry/migration.sql
new file mode 100644
index 0000000000..ace728b233
--- /dev/null
+++ b/queue-model/migrations/20231107195736_add_allow_retry/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE `Submission` ADD COLUMN `allow_retry` BOOLEAN NOT NULL DEFAULT true;
diff --git a/queue-model/migrations/20231108100812_webhook_url_required/migration.sql b/queue-model/migrations/20231108100812_webhook_url_required/migration.sql
new file mode 100644
index 0000000000..be5aafbc23
--- /dev/null
+++ b/queue-model/migrations/20231108100812_webhook_url_required/migration.sql
@@ -0,0 +1,5 @@
+-- UpdateColumn
+UPDATE `Submission` SET `webhook_url`='' WHERE `webhook_url` IS NULL;
+
+-- AlterTable
+ALTER TABLE `Submission` MODIFY `webhook_url` VARCHAR(191) NOT NULL;
diff --git a/queue-model/migrations/migration_lock.toml b/queue-model/migrations/migration_lock.toml
new file mode 100644
index 0000000000..e5a788a7af
--- /dev/null
+++ b/queue-model/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "mysql"
\ No newline at end of file
diff --git a/queue-model/package.json b/queue-model/package.json
new file mode 100644
index 0000000000..3a9f58e22b
--- /dev/null
+++ b/queue-model/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@xgovformbuilder/queue-model",
+ "version": "1.0.0",
+ "description": "A hapi plugin to provide the queue model for Xgov digital form builder based applications using the queue service",
+ "main": "dist/module/index.js",
+ "engines": {
+ "node": ">=16"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/XGovFormBuilder/digital-form-builder/tree/feat/failure-queue/queue-model"
+ },
+ "scripts": {
+ "lint": "yarn run eslint .",
+ "fix-lint": "yarn run eslint . --fix",
+ "build": "yarn run build:prisma && yarn run build:types && yarn run build:node",
+ "build:prisma": "prisma generate",
+ "migrate": "prisma migrate dev",
+ "build:node": "BABEL_ENV=node babel --extensions '.ts' src --out-dir dist/module --copy-files",
+ "build:types": "tsc --emitDeclarationOnly",
+ "type-check": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@babel/cli": "^7.23.3",
+ "@babel/core": "^7.23.3",
+ "@babel/eslint-parser": "^7.23.3",
+ "@babel/eslint-plugin": "^7.22.10",
+ "@babel/preset-env": "^7.23.3",
+ "@babel/preset-typescript": "^7.23.3",
+ "@types/node": "^20.4.6",
+ "babel-eslint": "^10.1.0",
+ "eslint": "^8.10.0",
+ "eslint-plugin-import": "^2.25.4",
+ "eslint-plugin-tsdoc": "^0.2.14",
+ "prisma": "^5.1.1",
+ "typescript": "4.9.5"
+ },
+ "dependencies": {
+ "@prisma/client": "5.16.0"
+ }
+}
diff --git a/queue-model/schema.prisma b/queue-model/schema.prisma
new file mode 100644
index 0000000000..78fe3a8424
--- /dev/null
+++ b/queue-model/schema.prisma
@@ -0,0 +1,21 @@
+datasource db {
+ provider = "mysql"
+ url = env("QUEUE_DATABASE_URL")
+}
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+model Submission {
+ id Int @id @default(autoincrement())
+ webhook_url String @default("")
+ created_at DateTime @default(now())
+ updated_at DateTime?
+ data String? @db.Text
+ error String?
+ return_reference String?
+ complete Boolean @default(false)
+ retry_counter Int
+ allow_retry Boolean @default(true)
+}
\ No newline at end of file
diff --git a/queue-model/src/index.ts b/queue-model/src/index.ts
new file mode 100644
index 0000000000..9c39c71b34
--- /dev/null
+++ b/queue-model/src/index.ts
@@ -0,0 +1,4 @@
+import * as path from "path";
+
+export { PrismaClient, Prisma, Submission } from "@prisma/client";
+export const SCHEMA_LOCATION = path.resolve(__dirname, "schema.prisma");
diff --git a/queue-model/tsconfig.json b/queue-model/tsconfig.json
new file mode 100644
index 0000000000..b0931dd737
--- /dev/null
+++ b/queue-model/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.json",
+ "baseUrl": "./src",
+ "compilerOptions": {
+ "outDir": "dist/module",
+ "rootDir": "src",
+ "composite": true,
+ "declaration": true,
+ "skipLibCheck": true
+ },
+ "include": ["./src"],
+ "exclude": ["../node_modules", "node_modules", "./src/prisma"]
+}
diff --git a/runner/Dockerfile b/runner/Dockerfile
index 1417314010..2aeb5a08e8 100644
--- a/runner/Dockerfile
+++ b/runner/Dockerfile
@@ -2,13 +2,15 @@
# Stage 1
# Base image contains the updated OS and
# It also configures the non-root user that will be given permission to copied files/folders in every subsequent stages
-FROM node:16-alpine AS base
-RUN mkdir -p /usr/src/app && \
+FROM node:18-alpine AS base
+RUN npm install -g npm@^9.x.x && \
+ mkdir -p /usr/src/app && \
addgroup -g 1001 appuser && \
adduser -S -u 1001 -G appuser appuser && \
chown -R appuser:appuser /usr/src/app && \
chmod -R +x /usr/src/app && \
apk update && \
+ apk upgrade && \
apk add --no-cache bash git
# ----------------------------
@@ -22,6 +24,7 @@ COPY --chown=appuser:appuser package.json yarn.lock .yarnrc.yml tsconfig.json .
COPY --chown=appuser:appuser model/package.json model/package.json
COPY --chown=appuser:appuser runner/package.json runner/package.json
COPY --chown=appuser:appuser designer/package.json designer/package.json
+COPY --chown=appuser:appuser queue-model/package.json queue-model/package.json
USER 1001
RUN --mount=type=cache,target=./.yarn/cache,id=base,uid=1001,mode=0755 yarn
@@ -42,21 +45,36 @@ RUN yarn model build
# ----------------------------
# Stage 4
+# Base with queue model stage
+# In this layer we build the queue-model workspace.
+# It will re-run only if anything inside /queue-model changes, otherwise this stage is cached.
+# rsync is used to merge folders instead of individually copying files
+FROM model AS queue-model
+WORKDIR /usr/src/app
+COPY --chown=appuser:appuser ./queue-model/package.json ./queue-model/tsconfig.json ./queue-model/babel.config.json ./queue-model/schema.prisma ./queue-model/
+COPY --chown=appuser:appuser ./queue-model/src ./queue-model/src/
+COPY --chown=appuser:appuser ./queue-model/migrations ./queue-model/migrations/
+RUN --mount=type=cache,target=.yarn/cache,uid=1001,mode=0755,id=queue-model \
+ --mount=type=cache,target=.yarn/cache,uid=1001,mode=0755,id=queue-model yarn workspaces focus @xgovformbuilder/queue-model
+RUN yarn queue-model build
+
+# ----------------------------
+# Stage 5
# Build stage
# In this layer we build the runner workspace
# It will re-run only if anything inside ./runner changes, otherwise this stage is cached.
# rsync is used to merge folders instead of individually copying files
-FROM model AS build-runner
+FROM queue-model AS build-runner
WORKDIR /usr/src/app
ARG LAST_COMMIT=""
ARG LAST_TAG=""
ENV LAST_COMMIT=$LAST_COMMIT
ENV LAST_TAG=$LAST_TAG
-COPY --chown=appuser:appuser ./runner/tsconfig.json ./runner/.babelrc ./runner/nodemon.json ./runner/
+COPY --chown=appuser:appuser ./runner/package.json ./runner/tsconfig.json ./runner/.babelrc ./runner/nodemon.json ./runner/
+COPY --chown=appuser:appuser ./runner/config ./runner/config
RUN --mount=type=cache,target=.yarn/cache,uid=1001,mode=0755,id=runner \
yarn workspaces focus @xgovformbuilder/runner
COPY --chown=appuser:appuser ./runner/src ./runner/src/
-COPY --chown=appuser:appuser ./runner/config ./runner/config
COPY --chown=appuser:appuser ./runner/bin ./runner/bin/
COPY --chown=appuser:appuser ./runner/public ./runner/public/
RUN touch runner/.env && \
diff --git a/runner/README.md b/runner/README.md
index 4feb7c5c04..c93e46cd20 100644
--- a/runner/README.md
+++ b/runner/README.md
@@ -57,32 +57,33 @@ To symlink an external .env file, for example inside a [Keybase](https://keybase
LINK_TO is optional, it defaults to `./${PROJECT_DIR}`.
### ⚠️ See [config](./config/default.js) for default values for each environment
-Please use a config file instead. This will give you more control over each environment.
+
+Please use a config file instead. This will give you more control over each environment.
The defaults can be found in [config](./config/default.js). Place your config files in `runner/config`
See [https://github.com/node-config/node-config#readme](https://github.com/node-config/node-config#readme) for more info.
-| name | description | required | default | valid | notes |
-|-------------------------|---------------------------------------|:-----------------------:|--------------|:---------------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------:|
-| NODE_ENV | Node environment | no | | development,test,production | |
-| PORT | Port number | no | 3009 | | |
-| OS_KEY | Ordnance Survey | no | | | For address lookup by postcode |
-| PAY_API_KEY | Pay api key | yes | | | |
-| PAY_RETURN_URL | Pay return url | yes | | | For GOV.UK Pay to redirect back to our service |
-| PAY_API_URL | Pay api url | yes | | | |
-| NOTIFY_TEMPLATE_ID | Notify api key | yes | | | Template ID required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. |
-| NOTIFY_API_KEY | Notify api key | yes | | | API KEY required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. |
-| GTM_ID_1 | Google Tag Manager ID 1 | no | | | |
-| GTM_ID_2 | Google Tag Manager ID 2 | no | | | |
-| MATOMO_URL | URL of Matomo | no | | | |
-| MATOMO_ID | ID of Matomo site | no | | | |
-| SSL_KEY | SSL Key | no | | | |
-| SSL_CERT | SSL Certificate | no | | | |
-| PREVIEW_MODE | Preview mode | no | false | | This should only be used in a dev or testing environment. Setting true will allow POST requests from the designer to add or mutate forms. |
-| LOG_LEVEL | Log level | no | debug | trace,debug,info,error | |
-| PRIVACY_POLICY_URL | The url used in footer's privacy link | no | help/privacy | | |
-| API_ENV | Switch for API keys | no | | test,production | If the JSON file supplies test and live API keys, this is used to switch between which key which needs to be used |
-| PHASE_TAG | Tag to use for phase banner | no | Beta | alpha, Beta, empty string | |
-| AUTH_ENABLED | Enable auth for all form pages | no | false | | |
+| name | description | required | default | valid | notes |
+| ----------------------- | ------------------------------------- | :---------------------: | ------------ | :-------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------: |
+| NODE_ENV | Node environment | no | | development,test,production | |
+| PORT | Port number | no | 3009 | | |
+| OS_KEY | Ordnance Survey | no | | | For address lookup by postcode |
+| PAY_API_KEY | Pay api key | yes | | | |
+| PAY_RETURN_URL | Pay return url | yes | | | For GOV.UK Pay to redirect back to our service |
+| PAY_API_URL | Pay api url | yes | | | |
+| NOTIFY_TEMPLATE_ID | Notify api key | yes | | | Template ID required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. |
+| NOTIFY_API_KEY | Notify api key | yes | | | API KEY required to send form payloads via [GOV.UK Notify](https://www.notifications.service.gov.uk) email service. |
+| GTM_ID_1 | Google Tag Manager ID 1 | no | | | |
+| GTM_ID_2 | Google Tag Manager ID 2 | no | | | |
+| MATOMO_URL | URL of Matomo | no | | | |
+| MATOMO_ID | ID of Matomo site | no | | | |
+| SSL_KEY | SSL Key | no | | | |
+| SSL_CERT | SSL Certificate | no | | | |
+| PREVIEW_MODE | Preview mode | no | false | | This should only be used in a dev or testing environment. Setting true will allow POST requests from the designer to add or mutate forms. |
+| LOG_LEVEL | Log level | no | debug | trace,debug,info,error | |
+| PRIVACY_POLICY_URL | The url used in footer's privacy link | no | help/privacy | | |
+| API_ENV | Switch for API keys | no | | test,production,smoke | If the JSON file supplies test and live API keys, this is used to switch between which key which needs to be used |
+| PHASE_TAG | Tag to use for phase banner | no | beta | alpha, beta, empty string | |
+| AUTH_ENABLED | Enable auth for all form pages | no | false | | |
| AUTH_CLIENT_ID | oAuth client ID | If AUTH_ENABLED is true | | | |
| AUTH_CLIENT_SECRET | oAuth client secret | If AUTH_ENABLED is true | | | |
| AUTH_CLIENT_AUTH_URL | oAuth client authorise endpoint | If AUTH_ENABLED is true | | | |
diff --git a/runner/config/custom-environment-variables.json b/runner/config/custom-environment-variables.json
index f089f5bde8..c1cd444708 100644
--- a/runner/config/custom-environment-variables.json
+++ b/runner/config/custom-environment-variables.json
@@ -13,6 +13,7 @@
"matomoUrl": "MATOMO_URL",
"payApiUrl": "PAY_API_URL",
"payReturnUrl": "PAY_RETURN_URL",
+ "payReferenceLength": "PAY_REFERENCE_LENGTH",
"serviceUrl": "SERVICE_URL",
"redisHost": "REDIS_HOST",
"redisPort": "REDIS_PORT",
@@ -41,5 +42,19 @@
"authClientTokenUrl": "AUTH_CLIENT_TOKEN_URL",
"authClientProfileUrl": "AUTH_CLIENT_PROFILE_URL",
"previewMode": "PREVIEW_MODE",
- "enforceCsrf": "ENFORCE_CSRF"
+ "enforceCsrf": "ENFORCE_CSRF",
+ "safelist": "SAFELIST",
+ "initialisedSessionTimeout": "INITIALISED_SESSION_TIMEOUT",
+ "initialisedSessionKey": "INITIALISED_SESSION_KEY",
+ "initialisedSessionAlgorithm": "INITIALISED_SESSION_ALGORITHM",
+ "enableQueueService": "ENABLE_QUEUE_SERVICE",
+ "queueType": "QUEUE_TYPE",
+ "queueDatabaseUrl": "QUEUE_DATABASE_URL",
+ "queueDatabaseUsername": "QUEUE_DATABASE_USERNAME",
+ "queueDatabasePassword": "QUEUE_DATABASE_PASSWORD",
+ "queueServicePollingInterval": "QUEUE_SERVICE_POLLING_INTERVAL",
+ "queueServicePollingTimeout": "QUEUE_SERVICE_POLLING_TIMEOUT",
+ "allowUserTemplates": "ALLOW_USER_TEMPLATES",
+ "maxClientFileSize": "MAX_CLIENT_FILE_SIZE",
+ "maxFileSizeStringInMb": "MAX_FILE_SIZE_STRING_IN_MB"
}
diff --git a/runner/config/default.js b/runner/config/default.js
index f0b0b57755..590b24c760 100644
--- a/runner/config/default.js
+++ b/runner/config/default.js
@@ -11,9 +11,9 @@ module.exports = {
* Initialised sessions
* Allows a user's state to be pre-populated.
*/
- safelist: [], // Array of hostnames you want to accept when using a session callback. eg "gov.uk".
- initialisedSessionTimeout: minute * 60 * 24 * 28, // Defaults to 28 days. Set the TTL for the initialised session
- initialisedSessionKey: `${nanoid.random(16)}`, // This should be set if you are deploying replicas
+ initialisedSessionTimeout: minute * 60 * 24 * 28, // Defaults to 28 days. Set the TTL for the initialised session in ms.
+ initialisedSessionKey: `${nanoid.random(16)}`, // This should be set if you are deploying replicas, otherwise the key will be different per replica
+ initialisedSessionAlgorithm: "HS512", // allowed algorithms: "RS256", "RS384", "RS512","PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512"
/**
* Server
@@ -70,7 +70,6 @@ module.exports = {
paymentSessionTimeout: 90 * minute, // GOV.UK Pay sessions are 90 minutes. It is possible a user takes longer than 20 minutes to complete a payment.
// - COMMENT OUT TO TEST WITHOUT REDIS -
// -----------------------------------------------------
-
sessionCookiePassword: "${SessionCookies.Password}",
redisHost: "${Redis.Host}",
redisPort: 6379,
@@ -108,7 +107,11 @@ module.exports = {
// Control which is used. Accepts "test" | "production" | "".
apiEnv: "",
payApiUrl: "https://publicapi.payments.service.gov.uk/v1",
- documentUploadApiUrl: "http://localhost:9000",
+ // payReferenceLength: "10" // The length of the string generated for GOV.UK Pay references.
+ // If both the api env and node env are set to "production", the pay return url will need to be secure.
+ // This is not the case if either are set to "test", or if the node env is set to "development"
+ // payReturnUrl: "http://localhost:3009"
+ // documentUploadApiUrl: "",
// ordnanceSurveyKey: "", // deprecated - this API is deprecated
// browserRefreshUrl: "", // deprecated - idk what this does
@@ -130,4 +133,23 @@ module.exports = {
logLevel: "info", // Accepts "trace" | "debug" | "info" | "warn" |"error"
logPrettyPrint: true,
logRedactPaths: ["req.headers['x-forwarded-for']"], // You should check your privacy policy before disabling this. Check https://getpino.io/#/docs/redaction on how to configure redaction paths
+
+ safelist: ["61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io"],
+
+ /**
+ * Failure queue
+ */
+ enableQueueService: false,
+ // queueType: "" // accepts "MYSQL" | "PGBOSS"
+ // queueDatabaseUrl: "mysql://root:root@localhost:3306/queue" | "postgresql://root:root@localhost:5432/queue
+ queueServicePollingInterval: "500", // How frequently to check the queue for a reference number
+ queueServicePollingTimeout: "2000", // Total time to wait for a reference number
+
+ allowUserTemplates: false,
+
+ /**
+ * File size errors
+ */
+ maxClientFileSize: 5 * 1024 * 1024, // 5MB
+ maxFileSizeStringInMb: "5", // The file size to render if the file is too large in MB
};
diff --git a/runner/config/production.json b/runner/config/production.json
index 9cec2c0a09..9a0bba2e85 100644
--- a/runner/config/production.json
+++ b/runner/config/production.json
@@ -1,5 +1,6 @@
{
"env": "production",
"logPrettyPrint": false,
- "enableCsrf": true
+ "enableCsrf": true,
+ "previewMode": false
}
diff --git a/runner/config/test.json b/runner/config/test.json
index 0d1436773e..315b6cfc55 100644
--- a/runner/config/test.json
+++ b/runner/config/test.json
@@ -1,8 +1,12 @@
{
- "safelist": ["webho.ok"],
+ "safelist": [
+ "61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io",
+ "webho.ok"
+ ],
"isTest": true,
"previewMode": true,
"enforceCsrf": false,
"initialisedSessionKey": "predictable-key",
- "env": "test"
+ "env": "test",
+ "documentUploadApiUrl": "http://localhost:9000"
}
diff --git a/runner/package.json b/runner/package.json
index 5548447835..3386f3ff32 100644
--- a/runner/package.json
+++ b/runner/package.json
@@ -17,9 +17,9 @@
"fix-lint": "yarn bin/run eslint . --fix",
"test": "yarn lint && yarn type-check && NODE_ENV=test yarn bin/run unit-test",
"test-cov": "yarn run unit-test-cov",
- "test:dev": "lab -T test/.transform.js -P test/**/*.test.* -v test --coverage-exclude",
- "unit-test": "lab -T test/.transform.js -P test/**/*.test.* -v test -S -v -r console -o stdout -r html -o unit-test.html -I version -l",
- "unit-test-cov": "lab -T test/.transform.js -P test/**/*.test.* -v test -t 83 -S -v -r console -o stdout -r lcov -o test-coverage/lab/lcov.info -r html -o test-coverage/lab/unit-test.html -r junit -o test-results/junit/unit-test.xml -I version -l",
+ "test:dev": "lab -T test/.transform.js -P (test|src)/**/*.test.* -v test --coverage-exclude",
+ "unit-test": "lab -T test/.transform.js -P (test|src)/**/*.test.* -v test -S -v -r console -o stdout -r html -o unit-test.html -I version -l",
+ "unit-test-cov": "lab -T test/.transform.js -P (test|src)/**/*.test.* -v test -t 83 -S -v -r console -o stdout -r lcov -o test-coverage/lab/lcov.info -r html -o test-coverage/lab/unit-test.html -r junit -o test-results/junit/unit-test.xml -I version -l",
"a11y": "node test/audit/components && node lighthouse",
"symlink-env": "./bin/symlink-config",
"type-check": "tsc --noEmit",
@@ -30,38 +30,38 @@
"url": "https://github.com/XGovFormBuilder/digital-form-builder/tree/main/builder"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
- "@babel/runtime": "^7.17.2",
- "@hapi/bell": "^12.3.0",
- "@hapi/boom": "^9.1.4",
- "@hapi/catbox": "11.1.0",
- "@hapi/catbox-memory": "^5.0.1",
- "@hapi/catbox-redis": "^6.0.2",
- "@hapi/cookie": "^11.0.2",
- "@hapi/crumb": "^8.0.1",
- "@hapi/hapi": "^20.2.2",
- "@hapi/inert": "^6.0.5",
- "@hapi/jwt": "^2.1.0",
- "@hapi/scooter": "^6.0.1",
- "@hapi/vision": "^6.1.0",
- "@hapi/wreck": "^17.1.0",
- "@hapi/yar": "^10.1.1",
+ "@babel/runtime": "^7.23.3",
+ "@hapi/bell": "^13.0.1",
+ "@hapi/boom": "^10.0.1",
+ "@hapi/catbox": "^12.1.1",
+ "@hapi/catbox-memory": "^6.0.1",
+ "@hapi/catbox-redis": "^7.0.2",
+ "@hapi/cookie": "^12.0.1",
+ "@hapi/crumb": "^9.0.1",
+ "@hapi/hapi": "^21.3.2",
+ "@hapi/inert": "^7.1.0",
+ "@hapi/jwt": "^3.2.0",
+ "@hapi/scooter": "^7.0.0",
+ "@hapi/vision": "^7.0.3",
+ "@hapi/wreck": "^18.0.1",
+ "@hapi/yar": "^11.0.1",
+ "@types/google-libphonenumber": "^7.4.30",
"accept-language-parser": "1.5.0",
"accessible-autocomplete": "^2.0.2",
"atob": "^2.1.2",
- "aws-sdk": "2.814.0",
"blankie": "5.0.0",
"blipp": "4.0.1",
"boom": "7.3.0",
"btoa": "^1.2.1",
- "concurrently": "^5.3.0",
"config": "^3.3.7",
"dotenv": "8.2.0",
"expr-eval": "^2.0.2",
- "govuk-frontend": "^5.3.1",
+ "google-libphonenumber": "^3.2.34",
+ "govuk-frontend": "^5.4.0",
"hapi-pino": "8.0.0",
"hapi-pulse": "3.0.0",
"hapi-rate-limit": "4.1.0",
@@ -69,11 +69,11 @@
"hmpo-components": "^5.2.1",
"ioredis": "4.16.1",
"joi": "17.2.1",
+ "lodash": "^4.17.21",
"nanoid": "^3.3.4",
- "nodemailer": "~6.6.0",
- "notifications-node-client": "^5.2.0",
+ "notifications-node-client": "^7.0.4",
"nunjucks": "^3.2.3",
- "pdfmake": "0.1.65",
+ "pg-boss": "^9.0.3",
"resolve": "^1.19.0",
"schmervice": "^1.6.0",
"tmp": "^0.2.1",
@@ -82,38 +82,37 @@
"yar": "9.1.0"
},
"devDependencies": {
- "@babel/cli": "^7.17.6",
- "@babel/core": "^7.18.10",
- "@babel/eslint-parser": "^7.17.0",
- "@babel/eslint-plugin": "^7.17.7",
- "@babel/plugin-proposal-class-properties": "^7.16.7",
- "@babel/plugin-proposal-export-default-from": "^7.16.7",
- "@babel/plugin-proposal-private-methods": "^7.16.11",
- "@babel/plugin-proposal-private-property-in-object": "^7.16.7",
- "@babel/plugin-transform-classes": "^7.16.7",
- "@babel/plugin-transform-modules-commonjs": "^7.17.7",
- "@babel/plugin-transform-runtime": "^7.17.0",
- "@babel/preset-env": "^7.16.11",
- "@babel/preset-typescript": "^7.16.7",
- "@babel/register": "^7.17.7",
+ "@babel/cli": "^7.23.3",
+ "@babel/core": "^7.23.3",
+ "@babel/eslint-parser": "^7.23.3",
+ "@babel/eslint-plugin": "^7.22.10",
+ "@babel/plugin-proposal-export-default-from": "^7.23.3",
+ "@babel/plugin-transform-classes": "^7.23.3",
+ "@babel/plugin-transform-modules-commonjs": "^7.23.3",
+ "@babel/plugin-transform-runtime": "^7.23.3",
+ "@babel/preset-env": "^7.23.3",
+ "@babel/preset-typescript": "^7.23.3",
+ "@babel/register": "^7.22.15",
"@hapi/code": "8.0.1",
"@hapi/lab": "24.0.0",
"@oclif/core": "^1.19.0",
"@types/hapi": "^18.0.7",
"@types/hapi__yar": "^10.1.1",
"@types/hoek": "^4.1.4",
+ "@types/mysql": "^2.15.21",
"@types/nodemailer": "^6.4.4",
- "@types/pino": "^7.0.5",
"@types/url-parse": "^1.4.8",
"@types/wreck": "^14.0.0",
"@xgovformbuilder/lab-babel": "2.1.2",
"@xgovformbuilder/model": "workspace:model",
+ "@xgovformbuilder/queue-model": "workspace:queue-model",
"acorn": "^8.7.0",
"babel-eslint": "^10.1.0",
"babel-plugin-module-name-mapper": "^1.2.0",
"cheerio": "^1.0.0-rc.10",
"chrome-launcher": "^0.13.4",
"code": "5.2.4",
+ "concurrently": "^8.0.0",
"date-fns": "^2.24.0",
"eslint": "^8.11.0",
"eslint-plugin-import": "^2.25.4",
@@ -121,13 +120,20 @@
"flat": "5.0.2",
"form-data": "^4.0.0",
"lodash-es": "^4.17.21",
- "nodemon": "^2.0.20",
+ "nodemon": "^3.0.2",
+ "pino": "8.15.1",
+ "prisma": "^5.1.1",
"sass": "^1.49.9",
"sinon": "^13.0.1",
- "typescript": "^4.0.3"
+ "typescript": "4.9.5"
},
"installConfig": {
"hoistingLimits": "dependencies",
"selfReferences": false
+ },
+ "pkg": {
+ "assets": [
+ "../node_modules/.prisma/client/*.node"
+ ]
}
}
diff --git a/runner/src/client/sass/application.scss b/runner/src/client/sass/application.scss
index d141ea8620..db6a3e95f5 100644
--- a/runner/src/client/sass/application.scss
+++ b/runner/src/client/sass/application.scss
@@ -73,6 +73,35 @@
}
}
+.govuk-button {
+ &--link {
+ @extend .govuk-link;
+ border: none;
+ color: $govuk-link-colour;
+ cursor: pointer;
+ background-color: transparent;
+ &:hover {
+ color: $govuk-link-hover-colour;
+ }
+ }
+}
+
+.govuk-list {
+ &--multi-start-page li {
+ list-style-type: none;
+ padding-left: 25px;
+ padding-right: 25px;
+ position: relative;
+ &:before {
+ content: "—";
+ position: absolute;
+ left: 0;
+ width: 20px;
+ overflow: hidden;
+ }
+ }
+}
+
.govuk-header__logotype {
fill: white;
}
diff --git a/runner/src/server/forms/ReportAnOutbreak.json b/runner/src/server/forms/ReportAnOutbreak.json
index eb736e3cfa..fb6656c335 100644
--- a/runner/src/server/forms/ReportAnOutbreak.json
+++ b/runner/src/server/forms/ReportAnOutbreak.json
@@ -1623,33 +1623,6 @@
}
]
},
- {
- "title": "general-1-to-5",
- "name": "pxxiab",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "1",
- "value": 1
- },
- {
- "text": "2 to 4",
- "value": "2 to 4"
- },
- {
- "text": "5 or more",
- "value": "5 or more"
- },
- {
- "text": "Not sure",
- "value": "Not sure"
- }
- ]
- },
{
"title": "clientele-types",
"name": "REslMD",
@@ -1677,29 +1650,6 @@
}
]
},
- {
- "title": "cases-none-some-all",
- "name": "rLdCDZ",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no resident/client cases)",
- "value": "Not applicable (there are no resident/client cases)"
- }
- ]
- },
{
"title": "area-1-more-note-sure-n-a",
"name": "DcWntj",
@@ -1723,25 +1673,6 @@
}
]
},
- {
- "title": "general-all-some-none",
- "name": "pqTQca",
- "type": "string",
- "items": [
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "None",
- "value": "None"
- }
- ]
- },
{
"title": "ipc-frequency",
"name": "qIZKqB",
@@ -1847,44 +1778,6 @@
}
]
},
- {
- "title": "general-confidence",
- "name": "tgnngq",
- "type": "string",
- "items": [
- {
- "text": "Completely confident",
- "value": "Completely confident"
- },
- {
- "text": "Quite confident",
- "value": "Quite confident"
- },
- {
- "text": "Not at all confident",
- "value": "Not at all confident"
- }
- ]
- },
- {
- "title": "general-urgancy",
- "name": "YFnxMY",
- "type": "string",
- "items": [
- {
- "text": "Very urgently",
- "value": "Very urgently"
- },
- {
- "text": "Soon",
- "value": "Soon"
- },
- {
- "text": "No hurry",
- "value": "No hurry"
- }
- ]
- },
{
"title": "feedback",
"name": "icNyDJ",
@@ -1931,11 +1824,11 @@
"items": [
{
"text": "0",
- "value": 0
+ "value": "0"
},
{
"text": "1",
- "value": 1
+ "value": "1"
},
{
"text": "2 to 3",
@@ -1955,29 +1848,6 @@
}
]
},
- {
- "title": "cases-non-some-all-staff",
- "name": "exEWIK",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no staff cases)",
- "value": "Not applicable (there are no staff cases)"
- }
- ]
- },
{
"title": "HPTs",
"name": "sjgMDe",
@@ -1997,165 +1867,6 @@
}
]
},
- {
- "title": "have-symptoms",
- "name": "IiMNZH",
- "type": "string",
- "items": [
- {
- "text": "< 5",
- "value": "< 5"
- },
- {
- "text": "5 - 10",
- "value": "5 - 10"
- },
- {
- "text": "11 - 20 ",
- "value": "11 - 20"
- },
- {
- "text": "More than 20",
- "value": "More than 20"
- }
- ]
- },
- {
- "title": "S4Q15",
- "name": "uGvTrx",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no staff cases)",
- "value": "Not applicable (there are no staff cases)"
- }
- ]
- },
- {
- "title": "S4Q14 a) In the same area/s of the setting",
- "name": "yUeLAZ",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no staff cases)",
- "value": "Not applicable (there are no staff cases)"
- }
- ]
- },
- {
- "title": "S4Q14 b)",
- "name": "akLMsZ",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- }
- ]
- },
- {
- "title": "S4Q14 b)",
- "name": "gYZUTi",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- }
- ]
- },
- {
- "title": "S4Q14 b) On the same shift",
- "name": "iosTNw",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- }
- ]
- },
- {
- "title": "S4Q14 b) On the same shift",
- "name": "mqatEz",
- "type": "string",
- "items": [
- {
- "text": "ss",
- "value": "ss"
- }
- ]
- },
- {
- "title": "None / Some / All / N/A",
- "name": "HgnBRj",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no staff cases)",
- "value": "Not applicable (there are no staff cases)"
- }
- ]
- },
- {
- "title": "S4Q13",
- "name": "XGdfrJ",
- "type": "string",
- "items": [
- {
- "text": "None",
- "value": "None"
- },
- {
- "text": "Some",
- "value": "Some"
- },
- {
- "text": "All",
- "value": "All"
- },
- {
- "text": "Not applicable (there are no resident/client cases)",
- "value": "Not applicable (there are no resident/client cases)"
- }
- ]
- },
{
"title": "s9s2 - 5 more more",
"name": "LiqmvS",
diff --git a/runner/src/server/forms/html-templating-example.json b/runner/src/server/forms/html-templating-example.json
new file mode 100644
index 0000000000..5f99246051
--- /dev/null
+++ b/runner/src/server/forms/html-templating-example.json
@@ -0,0 +1,63 @@
+{
+ "metadata": {},
+ "startPage": "/which-content-do-you-want-to-display",
+ "pages": [
+ {
+ "title": "Which content do you want to display?",
+ "path": "/which-content-do-you-want-to-display",
+ "components": [
+ {
+ "name": "contentToDisplay",
+ "options": { "exposeToContext": true },
+ "type": "RadiosField",
+ "title": "Content to display",
+ "list": "vYTQRu",
+ "nameHasError": false,
+ "values": { "type": "listRef" },
+ "schema": {}
+ }
+ ],
+ "next": [{ "path": "/second-page" }],
+ "section": "gcdSFb"
+ },
+ {
+ "path": "/second-page",
+ "title": "Dynamic page based on your answers: {{ gcdSFb.contentToDisplay }}",
+ "components": [
+ {
+ "name": "azSyOn",
+ "options": {},
+ "type": "Html",
+ "content": "This page demonstrates how templating works.
\nDepending on the answer you chose for the first question, you should see different content displayed below.
\nYou chosen the option: {{gcdSFb.contentToDisplay}}
\n{{additionalContexts.example[gcdSFb.contentToDisplay].additionalInfo | safe}}\nThe following list will have different items
\n\n{{additionalContexts.example[gcdSFb.contentToDisplay].listItems | safe }}\n
",
+ "schema": {}
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "Options",
+ "name": "vYTQRu",
+ "type": "string",
+ "items": [
+ { "text": "Answer 1", "value": "Answer 1" },
+ { "text": "Answer 2", "value": "Answer 2" }
+ ]
+ }
+ ],
+ "sections": [{ "name": "gcdSFb", "title": "section" }],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": { "allowSubmissionWithoutPayment": true, "maxAttempts": 3 }
+}
diff --git a/runner/src/server/forms/multi-start-page-example.json b/runner/src/server/forms/multi-start-page-example.json
new file mode 100644
index 0000000000..85e41e0e75
--- /dev/null
+++ b/runner/src/server/forms/multi-start-page-example.json
@@ -0,0 +1,129 @@
+{
+ "metadata": {},
+ "startPage": "/first-page",
+ "pages": [
+ {
+ "title": "Multi-page start example",
+ "path": "/first-page",
+ "components": [
+ {
+ "name": "sVKKuL",
+ "options": {},
+ "type": "Html",
+ "content": "\n
\nFirst page
\nThis is the first page
"
+ }
+ ],
+ "startPageNavigation": {
+ "next": {
+ "labelText": "Second page",
+ "href": "/multi-start-page-example/second-page"
+ }
+ },
+ "controller": "MultiStartPageController",
+ "next": [{ "path": "/second-page" }]
+ },
+ {
+ "path": "/second-page",
+ "title": "Multi-page start example",
+ "components": [
+ {
+ "name": "HDgwZU",
+ "options": {},
+ "type": "Html",
+ "content": "\n
\nSecond page
\nThis is the second page
"
+ }
+ ],
+ "startPageNavigation": {
+ "previous": {
+ "labelText": "First page",
+ "href": "/multi-start-page-example/first-page"
+ },
+ "next": {
+ "labelText": "Third page",
+ "href": "/multi-start-page-example/third-page"
+ }
+ },
+ "controller": "MultiStartPageController",
+ "next": [{ "path": "/third-page" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": []
+ },
+ {
+ "path": "/third-page",
+ "title": "Multi-page start example",
+ "components": [
+ {
+ "name": "sGXfIE",
+ "options": {},
+ "type": "Html",
+ "content": "\n
\nThird page
\nThis is the third page
"
+ }
+ ],
+ "startPageNavigation": {
+ "previous": {
+ "labelText": "Second page",
+ "href": "/multi-start-page-example/second-page"
+ },
+ "next": {
+ "labelText": "Fourth page",
+ "href": "/multi-start-page-example/fourth-page"
+ }
+ },
+ "controller": "MultiStartPageController",
+ "showContinueButton": true,
+ "continueButtonText": "Apply now",
+ "next": [{ "path": "/form-start" }]
+ },
+ {
+ "path": "/form-start",
+ "title": "Form start",
+ "components": [
+ {
+ "name": "ZOTkKs",
+ "options": {},
+ "type": "TextField",
+ "title": "First name"
+ },
+ {
+ "name": "yiaHAz",
+ "options": {},
+ "type": "TextField",
+ "title": "Surname"
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "path": "/fourth-page",
+ "title": "Multi-page start example",
+ "components": [
+ {
+ "name": "dkbFtn",
+ "options": {},
+ "type": "Html",
+ "content": "\n
\nFourth page
\nThis is the fourth page
"
+ }
+ ],
+ "startPageNavigation": {
+ "previous": {
+ "labelText": "Second page",
+ "href": "/multi-start-page-example/third-page"
+ }
+ },
+ "controller": "MultiStartPageController",
+ "next": [{ "path": "/third-page" }]
+ }
+ ],
+ "lists": [],
+ "sections": [],
+ "conditions": [],
+ "fees": [],
+ "outputs": [],
+ "version": 2,
+ "skipSummary": false,
+ "feeOptions": {}
+}
diff --git a/runner/src/server/forms/runner-components-test.json b/runner/src/server/forms/runner-components-test.json
index 3831649728..0efb9bbf47 100644
--- a/runner/src/server/forms/runner-components-test.json
+++ b/runner/src/server/forms/runner-components-test.json
@@ -243,47 +243,47 @@
"items": [
{
"text": "Alfa Romeo",
- "value": 1
+ "value": "alfa-romeo"
},
{
"text": "BMW",
- "value": 2
+ "value": "bmw"
},
{
"text": "Ford",
- "value": 3
+ "value": "ford"
},
{
"text": "Citroen",
- "value": 4
+ "value": "citroen"
},
{
"text": "Nissan",
- "value": 5
+ "value": "nissan"
},
{
"text": "Honda",
- "value": 6
+ "value": "honda"
},
{
"text": "Mercedes",
- "value": 8
+ "value": "mercedes"
},
{
"text": "Audi",
- "value": 9
+ "value": "audi"
},
{
"text": "Toyota",
- "value": 10
+ "value": "toyota"
},
{
"text": "Hyundai",
- "value": 11
+ "value": "hyundai"
},
{
"text": "Kia",
- "value": 12
+ "value": "kia"
}
]
},
@@ -294,23 +294,23 @@
"items": [
{
"text": "Diesel",
- "value": 1
+ "value": "diesel"
},
{
"text": "Electric",
- "value": 2
+ "value": "electric"
},
{
"text": "Hydrogen",
- "value": 3
+ "value": "hyrogen"
},
{
"text": "Petrol",
- "value": 4
+ "value": "petrol"
},
{
"text": "Hybrid",
- "value": 5
+ "value": "hybrid"
}
]
},
@@ -321,31 +321,31 @@
"items": [
{
"text": "Bath",
- "value": 1
+ "value": "bath"
},
{
"text": "Bristol",
- "value": 2
+ "value": "bristol"
},
{
"text": "Birmingham",
- "value": 3
+ "value": "birmingham"
},
{
"text": "Cardiff",
- "value": 4
+ "value": "cardiff"
},
{
"text": "Liverpool",
- "value": 6
+ "value": "liverpool"
},
{
"text": "Leeds",
- "value": 7
+ "value": "leeds"
},
{
"text": "Manchester",
- "value": 8
+ "value": "manchester"
}
]
},
@@ -356,15 +356,15 @@
"items": [
{
"text": "You are not a Robot",
- "value": 1
+ "value": "not-robot"
},
{
"text": "You have not previously claimed an exemption",
- "value": 2
+ "value": "not-claimed-exemption"
},
{
"text": "You have not omitted or purposely witheld information that might be detrimental to you claim",
- "value": 3
+ "value": "not-a-liar"
}
]
}
@@ -376,4 +376,4 @@
"outputs": [],
"version": 2,
"conditions": []
-}
\ No newline at end of file
+}
diff --git a/runner/src/server/forms/test.json b/runner/src/server/forms/test.json
index 59b6701e32..eb4d6364b9 100644
--- a/runner/src/server/forms/test.json
+++ b/runner/src/server/forms/test.json
@@ -469,11 +469,11 @@
"payApiKey": "",
"outputs": [
{
- "name": "Ric43H5Ctwl4NBDC9x1_4",
- "title": "email",
- "type": "email",
+ "name": "LwQeVI",
+ "title": "Test webhook",
+ "type": "webhook",
"outputConfiguration": {
- "emailAddress": "jennifermyanh.duong@digital.homeoffice.gov.uk"
+ "url": "https://61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io"
}
}
],
diff --git a/runner/src/server/index.ts b/runner/src/server/index.ts
index 2a306df5fa..b069c95b8d 100644
--- a/runner/src/server/index.ts
+++ b/runner/src/server/index.ts
@@ -26,15 +26,19 @@ import {
AddressService,
CacheService,
catboxProvider,
- EmailService,
NotifyService,
PayService,
StatusService,
UploadService,
+ MockUploadService,
WebhookService,
} from "./services";
import { HapiRequest, HapiResponseToolkit, RouteConfig } from "./types";
import getRequestInfo from "./utils/getRequestInfo";
+import { pluginQueue } from "server/plugins/queue";
+import { QueueStatusService } from "server/services/queueStatusService";
+import { MySqlQueueService } from "server/services/mySqlQueueService";
+import { PgBossQueueService } from "server/services/pgBossQueueService";
const serverOptions = (): ServerOptions => {
const hasCertificate = config.sslKey && config.sslCert;
@@ -57,7 +61,7 @@ const serverOptions = (): ServerOptions => {
includeSubDomains: true,
preload: false,
},
- xss: true,
+ xss: "enabled",
noSniff: true,
xframe: true,
},
@@ -106,12 +110,28 @@ async function createServer(routeConfig: RouteConfig) {
CacheService,
NotifyService,
PayService,
- UploadService,
- EmailService,
WebhookService,
- StatusService,
AddressService,
]);
+ if (!config.documentUploadApiUrl) {
+ server.registerService([
+ Schmervice.withName("uploadService", MockUploadService),
+ ]);
+ } else {
+ server.registerService([UploadService]);
+ }
+
+ if (config.enableQueueService) {
+ const queueType = config.queueType;
+ const queueService =
+ queueType === "PGBOSS" ? PgBossQueueService : MySqlQueueService;
+ server.registerService([
+ Schmervice.withName("queueService", queueService),
+ Schmervice.withName("statusService", QueueStatusService),
+ ]);
+ } else {
+ server.registerService(StatusService);
+ }
server.ext(
"onPreResponse",
@@ -123,7 +143,6 @@ async function createServer(routeConfig: RouteConfig) {
}
if ("header" in response && response.header) {
-
const WEBFONT_EXTENSIONS = /\.(?:eot|ttf|woff|svg|woff2)$/i;
if (!WEBFONT_EXTENSIONS.test(request.url.toString())) {
response.header(
@@ -162,6 +181,8 @@ async function createServer(routeConfig: RouteConfig) {
encoding: "base64json",
});
+ await server.register(pluginQueue);
+
return server;
}
diff --git a/runner/src/server/plugins/applicationStatus.ts b/runner/src/server/plugins/applicationStatus.ts
deleted file mode 100644
index f824772391..0000000000
--- a/runner/src/server/plugins/applicationStatus.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { redirectTo } from "./engine";
-import { HapiRequest, HapiResponseToolkit } from "../types";
-
-const applicationStatus = {
- plugin: {
- name: "applicationStatus",
- dependencies: "@hapi/vision",
- multiple: true,
- register: (server) => {
- server.route({
- method: "get",
- path: "/{id}/status",
- options: {
- pre: [
- {
- method: (request) => {
- const { statusService } = request.services([]);
- return statusService.shouldRetryPay(request);
- },
- assign: "shouldRetryPay",
- },
- {
- method: (request) => {
- const { cacheService } = request.services([]);
- return cacheService.getConfirmationState(request);
- },
- assign: "confirmationViewModel",
- },
- ],
- handler: async (request: HapiRequest, h: HapiResponseToolkit) => {
- const { statusService, cacheService } = request.services([]);
- const { params } = request;
- const form = server.app.forms[params.id];
-
- if (!!request.pre.confirmationViewModel?.confirmation) {
- request.logger.info(
- [`/${params.id}/status`],
- `${request.yar.id} confirmationViewModel found for user`
- );
- return h.view(
- "confirmation",
- request.pre.confirmationViewModel.confirmation
- );
- }
-
- if (request.pre.shouldRetryPay) {
- return h.view("pay-error", {
- errorList: ["there was a problem with your payment"],
- });
- }
-
- const state = await cacheService.getState(request);
-
- if (state?.userCompletedSummary !== true) {
- request.logger.error(
- [`/${params.id}/status`],
- `${request.yar.id} user has incomplete state`
- );
- return h.redirect(`/${params.id}/summary`);
- }
-
- const {
- reference: newReference,
- } = await statusService.outputRequests(request);
-
- const viewModel = statusService.getViewModel(
- state,
- form,
- newReference
- );
-
- await cacheService.setConfirmationState(request, {
- confirmation: viewModel,
- });
- await cacheService.clearState(request);
-
- return h.view("confirmation", viewModel);
- },
- },
- });
-
- server.route({
- method: "post",
- path: "/{id}/status",
- handler: async (request: HapiRequest, h: HapiResponseToolkit) => {
- const { payService, cacheService } = request.services([]);
- const { pay } = await cacheService.getState(request);
- const { meta } = pay;
- meta.attempts++;
- const res = await payService.retryPayRequest(pay);
-
- await cacheService.mergeState(request, {
- webhookData: {
- fees: {
- paymentReference: res.reference,
- },
- },
- pay: {
- payId: res.payment_id,
- reference: res.reference,
- self: res._links.self.href,
- meta,
- },
- });
- return redirectTo(request, h, res._links.next_url.href);
- },
- });
- },
- },
-};
-
-export default applicationStatus;
diff --git a/runner/src/server/plugins/applicationStatus/checkUserCompletedSummary.ts b/runner/src/server/plugins/applicationStatus/checkUserCompletedSummary.ts
new file mode 100644
index 0000000000..28926ce38e
--- /dev/null
+++ b/runner/src/server/plugins/applicationStatus/checkUserCompletedSummary.ts
@@ -0,0 +1,20 @@
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+
+export async function checkUserCompletedSummary(
+ request: HapiRequest,
+ h: HapiResponseToolkit
+) {
+ const { cacheService } = request.services([]);
+
+ const state = await cacheService.getState(request);
+
+ if (state?.userCompletedSummary !== true) {
+ request.logger.error(
+ [`/${request.params.id}/status`],
+ `${request.yar.id} user has incomplete state, redirecting to /summary`
+ );
+ return h.redirect(`/${request.params.id}/summary`).takeover();
+ }
+
+ return state.userCompletedSummary;
+}
diff --git a/runner/src/server/plugins/applicationStatus/handleUserWithConfirmationViewModel.ts b/runner/src/server/plugins/applicationStatus/handleUserWithConfirmationViewModel.ts
new file mode 100644
index 0000000000..127674980c
--- /dev/null
+++ b/runner/src/server/plugins/applicationStatus/handleUserWithConfirmationViewModel.ts
@@ -0,0 +1,36 @@
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+
+export async function handleUserWithConfirmationViewModel(
+ request: HapiRequest,
+ h: HapiResponseToolkit
+) {
+ const { cacheService } = request.services([]);
+
+ const confirmationViewModel = await cacheService.getConfirmationState(
+ request
+ );
+
+ if (!confirmationViewModel) {
+ return null;
+ }
+
+ const { redirectUrl, confirmation } = confirmationViewModel;
+
+ if (redirectUrl) {
+ request.logger.info(
+ [`/${request.params.id}/status`, request.yar.id],
+ `confirmationViewModel.redirect detected. User will be redirected to ${redirectUrl}`
+ );
+ return h.redirect(redirectUrl).takeover();
+ }
+
+ if (confirmation) {
+ request.logger.info(
+ [`/${request.params.id}/status`, request.yar.id],
+ `confirmationViewModel.confirmation detected. Re-presenting ${confirmation}`
+ );
+ return h.view("confirmation", confirmation).takeover();
+ }
+
+ return null;
+}
diff --git a/runner/src/server/plugins/applicationStatus/index.ts b/runner/src/server/plugins/applicationStatus/index.ts
new file mode 100644
index 0000000000..95cf414eae
--- /dev/null
+++ b/runner/src/server/plugins/applicationStatus/index.ts
@@ -0,0 +1,137 @@
+import { redirectTo } from "./../engine";
+import { HapiRequest, HapiResponseToolkit } from "../../types";
+import { retryPay } from "./retryPay";
+import { handleUserWithConfirmationViewModel } from "./handleUserWithConfirmationViewModel";
+import { checkUserCompletedSummary } from "./checkUserCompletedSummary";
+
+import Joi from "joi";
+import {
+ continueToPayAfterPaymentSkippedWarning,
+ paymentSkippedWarning,
+} from "./paymentSkippedWarning";
+
+const preHandlers = {
+ retryPay: {
+ method: retryPay,
+ assign: "shouldShowPayErrorPage",
+ },
+ handleUserWithConfirmationViewModel: {
+ method: handleUserWithConfirmationViewModel,
+ assign: "confirmationViewModel",
+ },
+ checkUserCompletedSummary: {
+ method: checkUserCompletedSummary,
+ assign: "userCompletedSummary",
+ },
+};
+
+const index = {
+ plugin: {
+ name: "applicationStatus",
+ dependencies: "@hapi/vision",
+ multiple: true,
+ register: (server) => {
+ server.route({
+ method: "get",
+ path: "/{id}/status",
+ options: {
+ pre: [
+ preHandlers.retryPay,
+ preHandlers.handleUserWithConfirmationViewModel,
+ preHandlers.checkUserCompletedSummary,
+ ],
+ handler: async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { statusService, cacheService } = request.services([]);
+ const { params } = request;
+ const form = server.app.forms[params.id];
+
+ const state = await cacheService.getState(request);
+
+ const {
+ reference: newReference,
+ } = await statusService.outputRequests(request);
+
+ if (state.callback?.skipSummary?.redirectUrl) {
+ const { redirectUrl } = state.callback?.skipSummary;
+ request.logger.info(
+ ["applicationStatus"],
+ `Callback skipSummary detected, redirecting ${request.yar.id} to ${redirectUrl} and clearing state`
+ );
+ await cacheService.setConfirmationState(request, {
+ redirectUrl,
+ });
+ await cacheService.clearState(request);
+
+ return h.redirect(redirectUrl);
+ }
+
+ const viewModel = statusService.getViewModel(
+ state,
+ form,
+ newReference
+ );
+
+ await cacheService.setConfirmationState(request, {
+ confirmation: viewModel,
+ });
+ await cacheService.clearState(request);
+
+ return h.view("confirmation", viewModel);
+ },
+ },
+ });
+
+ server.route({
+ method: "post",
+ path: "/{id}/status",
+ handler: async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { payService, cacheService } = request.services([]);
+ const { pay } = await cacheService.getState(request);
+ const { meta } = pay;
+ meta.attempts++;
+ const res = await payService.payRequestFromMeta(meta);
+
+ await cacheService.mergeState(request, {
+ webhookData: {
+ fees: {
+ paymentReference: res.reference,
+ },
+ },
+ pay: {
+ payId: res.payment_id,
+ reference: res.reference,
+ self: res._links.self.href,
+ meta,
+ },
+ });
+ return redirectTo(request, h, res._links.next_url.href);
+ },
+ });
+
+ server.route({
+ method: "get",
+ path: "/{id}/status/payment-skip-warning",
+ options: {
+ pre: [preHandlers.checkUserCompletedSummary],
+ handler: paymentSkippedWarning,
+ },
+ });
+
+ server.route({
+ method: "post",
+ path: "/{id}/status/payment-skip-warning",
+ options: {
+ handler: continueToPayAfterPaymentSkippedWarning,
+ validate: {
+ payload: Joi.object({
+ action: Joi.string().valid("pay").required(),
+ crumb: Joi.string(),
+ }),
+ },
+ },
+ });
+ },
+ },
+};
+
+export default index;
diff --git a/runner/src/server/plugins/applicationStatus/paymentSkippedWarning.ts b/runner/src/server/plugins/applicationStatus/paymentSkippedWarning.ts
new file mode 100644
index 0000000000..abe5496887
--- /dev/null
+++ b/runner/src/server/plugins/applicationStatus/paymentSkippedWarning.ts
@@ -0,0 +1,37 @@
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import { FormModel } from "server/plugins/engine/models";
+
+export async function paymentSkippedWarning(
+ request: HapiRequest,
+ h: HapiResponseToolkit
+) {
+ const form: FormModel = request.server.app.forms[request.params.id];
+ const { allowSubmissionWithoutPayment } = form.feeOptions;
+
+ if (allowSubmissionWithoutPayment) {
+ const { customText } = form.specialPages?.paymentSkippedWarningPage ?? {};
+ return h
+ .view("payment-skip-warning", {
+ customText,
+ backLink: "./../summary",
+ })
+ .takeover();
+ }
+
+ return h.redirect(`${request.params.id}/status`);
+}
+
+export async function continueToPayAfterPaymentSkippedWarning(
+ request: HapiRequest,
+ h: HapiResponseToolkit
+) {
+ const { cacheService } = request.services([]);
+ const state = await cacheService.getState(request);
+
+ const payState = state.pay;
+ payState.meta++;
+ await cacheService.mergeState(request, payState);
+
+ const payRedirectUrl = payState.next_url;
+ return h.redirect(payRedirectUrl);
+}
diff --git a/runner/src/server/plugins/applicationStatus/retryPay.ts b/runner/src/server/plugins/applicationStatus/retryPay.ts
new file mode 100644
index 0000000000..7395f43f13
--- /dev/null
+++ b/runner/src/server/plugins/applicationStatus/retryPay.ts
@@ -0,0 +1,27 @@
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import { FormModel } from "server/plugins/engine/models";
+
+export async function retryPay(request: HapiRequest, h: HapiResponseToolkit) {
+ const { statusService } = request.services([]);
+ const shouldShowPayErrorPage = await statusService.shouldShowPayErrorPage(
+ request
+ );
+
+ const form: FormModel = request.server.app.forms[request.params.id];
+ const feeOptions = form.feeOptions;
+ const {
+ allowSubmissionWithoutPayment = true,
+ customPayErrorMessage,
+ } = feeOptions;
+ if (shouldShowPayErrorPage) {
+ return h
+ .view("pay-error", {
+ errorList: ["there was a problem with your payment"],
+ allowSubmissionWithoutPayment,
+ customPayErrorMessage,
+ })
+ .takeover();
+ }
+
+ return shouldShowPayErrorPage;
+}
diff --git a/runner/src/server/plugins/blankie.ts b/runner/src/server/plugins/blankie.ts
index 4b061b8b10..9a87cd6d0c 100644
--- a/runner/src/server/plugins/blankie.ts
+++ b/runner/src/server/plugins/blankie.ts
@@ -45,21 +45,28 @@ export const configureBlankiePlugin = (
}
if (gtmId1 || gtmId2) {
- google.connectSrc.push("www.google-analytics.com", "region1.google-analytics.com");
+ google.connectSrc.push(
+ "www.google-analytics.com",
+ "region1.google-analytics.com"
+ );
google.fontSrc.push("fonts.gstatic.com");
google.frameSrc.push("www.googletagmanager.com");
google.imgSrc.push(
"www.gstatic.com",
"ssl.gstatic.com",
"www.googletagmanager.com",
- "www.google-analytics.com"
+ "region1.googletagmanager.com",
+ "www.google-analytics.com",
+ "region1.google-analytics.com"
);
google.scriptSrc.push(
"www.google-analytics.com",
"region1.google-analytics.com",
"ssl.google-analytics.com",
+ "region1.google-analytics.com",
"stats.g.doubleclick.net",
"www.googletagmanager.com",
+ "region1.googletagmanager.com",
"tagmanager.google.com",
"www.gstatic.com",
"ssl.gstatic.com"
diff --git a/runner/src/server/plugins/engine/components/AutocompleteField.ts b/runner/src/server/plugins/engine/components/AutocompleteField.ts
index 1ed3584980..616dfba1c3 100644
--- a/runner/src/server/plugins/engine/components/AutocompleteField.ts
+++ b/runner/src/server/plugins/engine/components/AutocompleteField.ts
@@ -8,6 +8,17 @@ import { FormSubmissionState } from "server/plugins/engine/types";
export class AutocompleteField extends SelectField {
constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);
+
+ let componentSchema = this.formSchema.messages({
+ "any.only": "Enter {{#label}}",
+ });
+ if (def.options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ def.options.customValidationMessages
+ );
+ }
+ this.formSchema = componentSchema;
+ this.stateSchema = componentSchema;
addClassOptionIfNone(this.options, "govuk-input--width-20");
}
getDisplayStringFromState(state: FormSubmissionState): string {
diff --git a/runner/src/server/plugins/engine/components/CheckboxesField.ts b/runner/src/server/plugins/engine/components/CheckboxesField.ts
index 444f531b25..73c3c7ba11 100644
--- a/runner/src/server/plugins/engine/components/CheckboxesField.ts
+++ b/runner/src/server/plugins/engine/components/CheckboxesField.ts
@@ -8,14 +8,23 @@ export class CheckboxesField extends SelectionControlField {
constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);
- let schema = joi
- .array()
- .items(joi[this.listType]().allow(...this.values))
- .single()
- .label(def.title);
-
- if (def.options.required !== false) {
- schema = schema.required();
+ const { options } = def;
+
+ let schema = joi.array().single().label(def.title);
+
+ if (options.required === false) {
+ // null or empty string is valid for optional fields
+ schema = schema
+ .empty(null)
+ .items(joi[this.listType]().allow(...this.values, ""));
+ } else {
+ schema = schema
+ .items(joi[this.listType]().allow(...this.values))
+ .required();
+ }
+
+ if (options.customValidationMessages) {
+ schema = schema.messages(options.customValidationMessages);
}
this.formSchema = schema;
diff --git a/runner/src/server/plugins/engine/components/ComponentCollection.ts b/runner/src/server/plugins/engine/components/ComponentCollection.ts
index f377de6a3c..42354efb0e 100644
--- a/runner/src/server/plugins/engine/components/ComponentCollection.ts
+++ b/runner/src/server/plugins/engine/components/ComponentCollection.ts
@@ -12,10 +12,12 @@ import {
import { ComponentCollectionViewModel } from "./types";
import { ComponentBase } from "./ComponentBase";
import { FormComponent } from "./FormComponent";
+import { merge } from "@hapi/hoek";
export class ComponentCollection {
items: (ComponentBase | ComponentCollection | FormComponent)[];
formItems: FormComponent /* | ConditionalFormComponent*/[];
+ prePopulatedItems: Record;
formSchema: JoiSchema;
stateSchema: JoiSchema;
@@ -43,6 +45,7 @@ export class ComponentCollection {
.keys({ crumb: joi.string().optional().allow("") });
this.stateSchema = joi.object().keys(this.getStateSchemaKeys()).required();
+ this.prePopulatedItems = this.getPrePopulatedItems();
}
getFormSchemaKeys() {
@@ -65,6 +68,24 @@ export class ComponentCollection {
return keys;
}
+ getPrePopulatedItems() {
+ return this.formItems
+ .filter((item) => item.options?.allowPrePopulation)
+ .map((item) => {
+ // to access the schema we need to use the component name to retrieve the value from getStateSchemaKeys
+ const schema = item.getStateSchemaKeys()[item.name];
+
+ return {
+ [item.name]: {
+ schema,
+ allowPrePopulationOverwrite:
+ item.options.allowPrePopulationOverwrite,
+ },
+ };
+ })
+ .reduce((acc, curr) => merge(acc, curr), {});
+ }
+
getFormDataFromState(state: FormSubmissionState): any {
const formData = {};
diff --git a/runner/src/server/plugins/engine/components/ContextComponent.ts b/runner/src/server/plugins/engine/components/ContextComponent.ts
new file mode 100644
index 0000000000..a38f005459
--- /dev/null
+++ b/runner/src/server/plugins/engine/components/ContextComponent.ts
@@ -0,0 +1,34 @@
+import { FormComponent } from "server/plugins/engine/components/FormComponent";
+import { ComponentDef, Page } from "@xgovformbuilder/model";
+import { FormModel } from "server/plugins/engine/models";
+import { FormSubmissionState } from "server/plugins/engine/types";
+import _ from "lodash";
+
+export class ContextComponent extends FormComponent {
+ section: Page["section"];
+ constructor(def: ComponentDef, model: FormModel) {
+ super(def, model);
+ this.section = def.section;
+ }
+
+ getFormDataFromState(state: FormSubmissionState) {
+ const name = this.name;
+ const section = this.section;
+ let path = "";
+ const result = {};
+
+ if (section && section in state) {
+ path = `${section}.`;
+ state = {
+ ...state[section],
+ };
+ }
+
+ if (name in state) {
+ _.set(result, `${path}${name}`, this.getFormValueFromState(state));
+ return result;
+ }
+
+ return undefined;
+ }
+}
diff --git a/runner/src/server/plugins/engine/components/ContextComponentCollection.ts b/runner/src/server/plugins/engine/components/ContextComponentCollection.ts
new file mode 100644
index 0000000000..806febac5d
--- /dev/null
+++ b/runner/src/server/plugins/engine/components/ContextComponentCollection.ts
@@ -0,0 +1,42 @@
+import { ComponentCollection } from "server/plugins/engine/components/ComponentCollection";
+import { FormModel } from "server/plugins/engine/models";
+import { FormSubmissionState } from "server/plugins/engine/types";
+import { FormComponent } from "server/plugins/engine/components/FormComponent";
+import { reach } from "@hapi/hoek";
+import _ from "lodash";
+import { ContextComponent } from "server/plugins/engine/components/ContextComponent";
+import { ComponentBase } from "server/plugins/engine/components/ComponentBase";
+
+export class ContextComponentCollection extends ComponentCollection {
+ items: (
+ | ComponentBase
+ | ComponentCollection
+ | FormComponent
+ | ContextComponent
+ )[];
+ constructor(model: FormModel) {
+ const exposedComponentDefs = model.def.pages.flatMap((page) => {
+ return (
+ page.components
+ ?.filter((component) => component.options?.exposeToContext)
+ .map((component) => ({
+ ...component,
+ type: "ContextComponent",
+ section: page.section,
+ })) ?? []
+ );
+ });
+
+ super(exposedComponentDefs, model);
+ }
+
+ getFormDataFromState(state: FormSubmissionState): any {
+ const formData = {};
+
+ this.items.forEach((item: ContextComponent) => {
+ Object.assign(formData, item.getFormDataFromState(state));
+ });
+
+ return formData;
+ }
+}
diff --git a/runner/src/server/plugins/engine/components/DatePartsField.ts b/runner/src/server/plugins/engine/components/DatePartsField.ts
index 343afa755c..ef32ec337f 100644
--- a/runner/src/server/plugins/engine/components/DatePartsField.ts
+++ b/runner/src/server/plugins/engine/components/DatePartsField.ts
@@ -36,6 +36,11 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 1 and 31",
+ "number.max": "{{#label}} must be between 1 and 31",
+ "number.base": `${def.title} must include a day`,
+ },
},
hint: "",
},
@@ -48,6 +53,11 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 1 and 12",
+ "number.max": "{{#label}} must be between 1 and 12",
+ "number.base": `${def.title} must include a month`,
+ },
},
hint: "",
},
@@ -60,6 +70,9 @@ export class DatePartsField extends FormComponent {
required: isRequired,
optionalText: optionalText,
classes: "govuk-input--width-4",
+ customValidationMessages: {
+ "number.base": `${def.title} must include a year`,
+ },
},
hint: "",
},
@@ -82,6 +95,9 @@ export class DatePartsField extends FormComponent {
schema = schema.custom(
helpers.getCustomDateValidator(maxDaysInPast, maxDaysInFuture)
);
+ // if (options.customValidationMessages) {
+ // schema = schema.messages(options.customValidationMessages);
+ // }
this.schema = schema;
diff --git a/runner/src/server/plugins/engine/components/DateTimePartsField.ts b/runner/src/server/plugins/engine/components/DateTimePartsField.ts
index a57dbce3b7..6ea6d8611b 100644
--- a/runner/src/server/plugins/engine/components/DateTimePartsField.ts
+++ b/runner/src/server/plugins/engine/components/DateTimePartsField.ts
@@ -32,6 +32,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 1 and 31",
+ "number.max": "{{#label}} must be between 1 and 31",
+ "number.base": `${def.title} must include a day`,
+ },
},
},
{
@@ -42,6 +47,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 1 and 12",
+ "number.max": "{{#label}} must be between 1 and 12",
+ "number.base": `${def.title} must include a month`,
+ },
},
},
{
@@ -52,6 +62,9 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-4",
+ customValidationMessages: {
+ "number.base": `${def.title} must include a year`,
+ },
},
},
{
@@ -62,6 +75,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 0 and 23",
+ "number.max": "{{#label}} must be between 0 and 23",
+ "number.base": `${def.title} must include an hour`,
+ },
},
},
{
@@ -72,6 +90,11 @@ export class DateTimePartsField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 0 and 59",
+ "number.max": "{{#label}} must be between 0 and 59",
+ "number.base": `${def.title} must include a minute`,
+ },
},
},
] as any,
diff --git a/runner/src/server/plugins/engine/components/FileUploadField.ts b/runner/src/server/plugins/engine/components/FileUploadField.ts
index dd179b47cc..f8d558ea1b 100644
--- a/runner/src/server/plugins/engine/components/FileUploadField.ts
+++ b/runner/src/server/plugins/engine/components/FileUploadField.ts
@@ -1,17 +1,43 @@
import { FormData, FormSubmissionErrors } from "../types";
import { FormComponent } from "./FormComponent";
-import * as helpers from "./helpers";
import { DataType, ViewModel } from "./types";
+import { FileUploadFieldComponent } from "@xgovformbuilder/model";
+import { FormModel } from "server/plugins/engine/models";
+import joi, { Schema } from "joi";
export class FileUploadField extends FormComponent {
dataType = "file" as DataType;
+
+ constructor(def: FileUploadFieldComponent, model: FormModel) {
+ super(def, model);
+
+ const { options = {} } = def;
+
+ let componentSchema = joi.string().label(def.title);
+
+ if (options.required === false) {
+ componentSchema = componentSchema.allow("").allow(null);
+ }
+
+ componentSchema = componentSchema.messages({
+ "string.empty": "Upload {{#label}}",
+ });
+
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
+
+ this.schema = componentSchema;
+ }
getFormSchemaKeys() {
- return helpers.getFormSchemaKeys(this.name, "string", this);
+ return { [this.name]: this.schema as Schema };
}
getStateSchemaKeys() {
- return helpers.getStateSchemaKeys(this.name, "string", this);
+ return { [this.name]: this.schema as Schema };
}
get attributes() {
diff --git a/runner/src/server/plugins/engine/components/Html.ts b/runner/src/server/plugins/engine/components/Html.ts
index be379e41c0..0b57ea3be0 100644
--- a/runner/src/server/plugins/engine/components/Html.ts
+++ b/runner/src/server/plugins/engine/components/Html.ts
@@ -1,12 +1,18 @@
import { FormData, FormSubmissionErrors } from "../types";
import { ComponentBase } from "./ComponentBase";
+import config from "../../../config";
+import nunjucks from "nunjucks";
export class Html extends ComponentBase {
getViewModel(formData: FormData, errors: FormSubmissionErrors) {
const { options } = this;
+ let content = this.content;
+ if (config.allowUserTemplates) {
+ content = nunjucks.renderString(content, { ...formData });
+ }
const viewModel = {
...super.getViewModel(formData, errors),
- content: this.content,
+ content: content,
};
if ("condition" in options && options.condition) {
diff --git a/runner/src/server/plugins/engine/components/ListFormComponent.ts b/runner/src/server/plugins/engine/components/ListFormComponent.ts
index 5b45196afd..75a1d39514 100644
--- a/runner/src/server/plugins/engine/components/ListFormComponent.ts
+++ b/runner/src/server/plugins/engine/components/ListFormComponent.ts
@@ -23,24 +23,34 @@ export class ListFormComponent extends FormComponent {
constructor(def: ListComponentsDef, model: FormModel) {
super(def, model);
+ const { options } = def;
// @ts-ignore
this.list = model.getList(def.list);
this.listType = this.list.type ?? "string";
- this.options = def.options;
+ this.options = options;
+
+ let componentSchema = joi[this.listType]();
/**
* Only allow a user to answer with values that have been defined in the list
*/
- let schema = joi[this.listType]()
- .allow(...this.values)
- .label(def.title);
+ if (options.required === false) {
+ // null or empty string is valid for optional fields
+ componentSchema = componentSchema.empty(null).valid(...this.values, "");
+ } else {
+ componentSchema = componentSchema.valid(...this.values).required();
+ }
- if (def.options.required !== false) {
- schema = schema.required();
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
}
- this.formSchema = schema;
- this.stateSchema = schema;
+ componentSchema = componentSchema.label(def.title);
+
+ this.formSchema = componentSchema;
+ this.stateSchema = componentSchema;
}
getFormSchemaKeys() {
@@ -54,7 +64,9 @@ export class ListFormComponent extends FormComponent {
getDisplayStringFromState(state: FormSubmissionState): string | string[] {
const { name, items } = this;
const value = state[name];
- const item = items.find((item) => String(item.value) === String(value));
+ const item = items.find((item) => {
+ return String(item.value) === String(value);
+ });
return `${item?.text ?? ""}`;
}
diff --git a/runner/src/server/plugins/engine/components/MonthYearField.ts b/runner/src/server/plugins/engine/components/MonthYearField.ts
index 335a60dead..efe9b5fbf5 100644
--- a/runner/src/server/plugins/engine/components/MonthYearField.ts
+++ b/runner/src/server/plugins/engine/components/MonthYearField.ts
@@ -30,7 +30,11 @@ export class MonthYearField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-2",
- customValidationMessage: "{{label}} must be between 1 and 12",
+ customValidationMessages: {
+ "number.min": "{{#label}} must be between 1 and 12",
+ "number.max": "{{#label}} must be between 1 and 12",
+ "number.base": `${def.title} must include a month`,
+ },
},
},
{
@@ -41,6 +45,9 @@ export class MonthYearField extends FormComponent {
options: {
required: options.required,
classes: "govuk-input--width-4",
+ customValidationMessages: {
+ "number.base": `${def.title} must include a year`,
+ },
},
},
] as any,
@@ -59,7 +66,13 @@ export class MonthYearField extends FormComponent {
}
getFormDataFromState(state: FormSubmissionState) {
- return this.children.getFormDataFromState(state);
+ const name = this.name;
+ const value = state[name];
+
+ return {
+ [`${name}__month`]: value && value[`${name}__month`],
+ [`${name}__year`]: value && value[`${name}__year`],
+ };
}
getStateValueFromValidForm(payload: FormPayload) {
diff --git a/runner/src/server/plugins/engine/components/MultilineTextField.ts b/runner/src/server/plugins/engine/components/MultilineTextField.ts
index 38c30890a0..a99640aea6 100644
--- a/runner/src/server/plugins/engine/components/MultilineTextField.ts
+++ b/runner/src/server/plugins/engine/components/MultilineTextField.ts
@@ -23,32 +23,27 @@ export class MultilineTextField extends FormComponent {
constructor(def: MultilineTextFieldComponent, model: FormModel) {
super(def, model);
- this.options = def.options;
- this.schema = def.schema;
- this.formSchema = Joi.string();
- this.formSchema = this.formSchema.label(def.title);
- const { maxWords, customValidationMessage } = def.options;
- const isRequired = def.options.required ?? true;
-
- if (isRequired) {
- this.formSchema = this.formSchema.required();
- } else {
- this.formSchema = this.formSchema.allow("").allow(null);
+ const { schema = {}, options } = def;
+ this.options = options;
+ this.schema = schema;
+ let componentSchema = Joi.string().label(def.title).required();
+
+ if (options.required === false) {
+ componentSchema = componentSchema.allow("").allow(null);
}
- this.formSchema = this.formSchema.ruleset;
- if (def.schema.max) {
- this.formSchema = this.formSchema.max(def.schema.max);
+ if (schema.max) {
+ componentSchema = componentSchema.max(schema.max);
this.isCharacterOrWordCount = true;
}
- if (def.schema.min) {
- this.formSchema = this.formSchema.min(def.schema.min);
+ if (schema.min) {
+ componentSchema = componentSchema.min(schema.min);
}
- if (maxWords ?? false) {
- this.formSchema = this.formSchema.custom((value, helpers) => {
- if (inputIsOverWordCount(value, maxWords)) {
+ if (options.maxWords ?? false) {
+ componentSchema = componentSchema.custom((value, helpers) => {
+ if (inputIsOverWordCount(value, options.maxWords)) {
helpers.error("string.maxWords");
}
return value;
@@ -56,11 +51,19 @@ export class MultilineTextField extends FormComponent {
this.isCharacterOrWordCount = true;
}
- if (customValidationMessage) {
- this.formSchema = this.formSchema.rule({
- message: customValidationMessage,
+ if (options.customValidationMessage) {
+ componentSchema = componentSchema.rule({
+ message: options.customValidationMessage,
});
}
+
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
+
+ this.formSchema = componentSchema;
}
getFormSchemaKeys() {
diff --git a/runner/src/server/plugins/engine/components/NumberField.ts b/runner/src/server/plugins/engine/components/NumberField.ts
index 747114c773..3ff70f91ee 100644
--- a/runner/src/server/plugins/engine/components/NumberField.ts
+++ b/runner/src/server/plugins/engine/components/NumberField.ts
@@ -9,35 +9,49 @@ export class NumberField extends FormComponent {
constructor(def, model) {
super(def, model);
- this.schemaOptions = def.schema;
- this.options = def.options;
- const { min, max } = def.schema;
- let schema = joi.number();
- schema = schema.label(def.title);
+ const { schema = {}, options } = def;
- if (def.schema?.min && def.schema?.max) {
- schema = schema.$;
+ this.schemaOptions = schema;
+ this.options = options;
+ const { min, max } = schema;
+ let componentSchema = joi.number();
+
+ componentSchema = componentSchema.label(def.title);
+
+ if (min && max) {
+ componentSchema = componentSchema.$;
}
- if (def.schema?.min ?? false) {
- schema = schema.min(min);
+ if (min ?? false) {
+ componentSchema = componentSchema.min(min);
+ }
+
+ if (max ?? false) {
+ componentSchema = componentSchema.max(max);
}
- if (def.schema?.max ?? false) {
- schema = schema.max(max);
+ if (options.customValidationMessage) {
+ componentSchema = componentSchema.rule({
+ message: def.options.customValidationMessage,
+ });
}
- if (def.options.customValidationMessage) {
- schema = schema.rule({ message: def.options.customValidationMessage });
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
}
if (def.options.required === false) {
const optionalSchema = joi
.alternatives()
- .try(joi.string().allow(null).allow("").default("").optional(), schema);
+ .try(
+ joi.string().allow(null).allow("").default("").optional(),
+ componentSchema
+ );
this.schema = optionalSchema;
} else {
- this.schema = schema;
+ this.schema = componentSchema;
}
}
diff --git a/runner/src/server/plugins/engine/components/SelectionControlField.ts b/runner/src/server/plugins/engine/components/SelectionControlField.ts
index 1b0346e1f6..dab417cb18 100644
--- a/runner/src/server/plugins/engine/components/SelectionControlField.ts
+++ b/runner/src/server/plugins/engine/components/SelectionControlField.ts
@@ -34,7 +34,6 @@ export class SelectionControlField extends ListFormComponent {
html: this.localisedString(item.description),
};
}
-
return itemModel;
// FIXME:- add this back when GDS fix accessibility issues involving conditional reveal fields
diff --git a/runner/src/server/plugins/engine/components/TelephoneNumberField.ts b/runner/src/server/plugins/engine/components/TelephoneNumberField.ts
index 33d1c91bda..47d75c7d61 100644
--- a/runner/src/server/plugins/engine/components/TelephoneNumberField.ts
+++ b/runner/src/server/plugins/engine/components/TelephoneNumberField.ts
@@ -2,7 +2,7 @@ import { TelephoneNumberFieldComponent } from "@xgovformbuilder/model";
import { FormComponent } from "./FormComponent";
import { FormModel } from "../models";
-import { addClassOptionIfNone } from "./helpers";
+import { addClassOptionIfNone, internationalPhoneValidator } from "./helpers";
import { FormData, FormSubmissionErrors } from "../types";
import joi, { Schema } from "joi";
@@ -15,8 +15,6 @@ import joi, { Schema } from "joi";
const PATTERN = /^((\+\d{0,4})|(0))[0-9\s()+]{0,20}$/;
const DEFAULT_MESSAGE = "Enter a telephone number in the correct format";
-const REQUIRED_MESSAGE =
- "Enter a telephone number, like 01632 960 001, 07700 900 982 or +44 808 157 0192";
export class TelephoneNumberField extends FormComponent {
constructor(def: TelephoneNumberFieldComponent, model: FormModel) {
super(def, model);
@@ -30,14 +28,7 @@ export class TelephoneNumberField extends FormComponent {
}
componentSchema = componentSchema
.pattern(pattern)
- .messages({
- "any.required":
- def.options?.requiredFieldValidationMessage ?? REQUIRED_MESSAGE,
- "string.empty":
- def.options?.requiredFieldValidationMessage ?? REQUIRED_MESSAGE,
- "string.pattern.base":
- def.options?.customValidationMessage ?? DEFAULT_MESSAGE,
- })
+ .message(def.options?.customValidationMessage ?? DEFAULT_MESSAGE)
.label(def.title);
if (schema.max) {
@@ -48,6 +39,15 @@ export class TelephoneNumberField extends FormComponent {
componentSchema = componentSchema.min(schema.min);
}
+ if (options.isInternational) {
+ componentSchema = componentSchema.custom(internationalPhoneValidator);
+ }
+
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
this.schema = componentSchema;
addClassOptionIfNone(this.options, "govuk-input--width-10");
diff --git a/runner/src/server/plugins/engine/components/TextField.ts b/runner/src/server/plugins/engine/components/TextField.ts
index e06d12fd88..846584e3df 100644
--- a/runner/src/server/plugins/engine/components/TextField.ts
+++ b/runner/src/server/plugins/engine/components/TextField.ts
@@ -47,6 +47,12 @@ export class TextField extends FormComponent {
);
}
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
+
this.formSchema = componentSchema;
}
diff --git a/runner/src/server/plugins/engine/components/TimeField.ts b/runner/src/server/plugins/engine/components/TimeField.ts
index 1b433084eb..55f6e28a41 100644
--- a/runner/src/server/plugins/engine/components/TimeField.ts
+++ b/runner/src/server/plugins/engine/components/TimeField.ts
@@ -5,19 +5,30 @@ import { FormComponent } from "./FormComponent";
import { FormModel } from "../models";
import { addClassOptionIfNone } from "./helpers";
import { FormData, FormSubmissionErrors } from "../types";
+import { Schema } from "joi";
export class TimeField extends FormComponent {
constructor(def: InputFieldsComponentsDef, model: FormModel) {
super(def, model);
addClassOptionIfNone(this.options, "govuk-input--width-4");
+ const { options } = def;
+ let componentSchema = helpers.getFormSchemaKeys(this.name, "string", this)[
+ this.name
+ ];
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
+ this.formSchema = componentSchema;
}
getFormSchemaKeys() {
- return helpers.getFormSchemaKeys(this.name, "string", this);
+ return { [this.name]: this.formSchema as Schema };
}
getStateSchemaKeys() {
- return helpers.getStateSchemaKeys(this.name, "string", this);
+ return { [this.name]: this.formSchema as Schema };
}
getViewModel(formData: FormData, errors: FormSubmissionErrors) {
diff --git a/runner/src/server/plugins/engine/components/YesNoField.ts b/runner/src/server/plugins/engine/components/YesNoField.ts
index 230feb5f5c..8fce66bb0d 100644
--- a/runner/src/server/plugins/engine/components/YesNoField.ts
+++ b/runner/src/server/plugins/engine/components/YesNoField.ts
@@ -40,12 +40,18 @@ export class YesNoField extends ListFormComponent {
const { options } = this;
- this.formSchema = helpers
+ let componentSchema = helpers
.buildFormSchema("boolean", this, options?.required !== false)
.valid(true, false);
- this.stateSchema = helpers
- .buildStateSchema(this.list.type, this)
- .valid(true, false);
+
+ if (options.customValidationMessages) {
+ componentSchema = componentSchema.messages(
+ options.customValidationMessages
+ );
+ }
+
+ this.formSchema = componentSchema;
+ this.stateSchema = componentSchema;
addClassOptionIfNone(this.options, "govuk-radios--inline");
}
diff --git a/runner/src/server/plugins/engine/components/constants.ts b/runner/src/server/plugins/engine/components/constants.ts
index 87965f0f94..8c0fb97914 100644
--- a/runner/src/server/plugins/engine/components/constants.ts
+++ b/runner/src/server/plugins/engine/components/constants.ts
@@ -1 +1 @@
-export const optionalText = " (Optional)";
+export const optionalText = " (optional)";
diff --git a/runner/src/server/plugins/engine/components/helpers.ts b/runner/src/server/plugins/engine/components/helpers.ts
index 435c240a09..0397d3b7c1 100644
--- a/runner/src/server/plugins/engine/components/helpers.ts
+++ b/runner/src/server/plugins/engine/components/helpers.ts
@@ -1,5 +1,8 @@
import joi from "joi";
import { add, startOfToday, sub } from "date-fns";
+import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
+
+const phoneUtil = PhoneNumberUtil.getInstance();
/**
* FIXME:- this code is bonkers. buildFormSchema and buildState schema are duplicates.
@@ -48,7 +51,9 @@ export function buildStateSchema(schemaType, component) {
if (component.title) {
schema = schema.label(
- typeof component.title === "string" ? component.title : component.title.en
+ typeof component.title === "string"
+ ? component.title.toLowerCase()
+ : component.title.en.toLowerCase()
);
}
@@ -122,3 +127,11 @@ export function getCustomDateValidator(
return value;
};
}
+
+export function internationalPhoneValidator(
+ value: string,
+ _helpers: joi.CustomHelpers
+) {
+ const phone = phoneUtil.parseAndKeepRawInput(value);
+ return phoneUtil.format(phone, PhoneNumberFormat.INTERNATIONAL);
+}
diff --git a/runner/src/server/plugins/engine/components/index.ts b/runner/src/server/plugins/engine/components/index.ts
index d3ca880d28..fab35d4651 100644
--- a/runner/src/server/plugins/engine/components/index.ts
+++ b/runner/src/server/plugins/engine/components/index.ts
@@ -33,3 +33,4 @@ export { UkAddressField } from "./UkAddressField";
export { WebsiteField } from "./WebsiteField";
export { YesNoField } from "./YesNoField";
export { MonthYearField } from "./MonthYearField";
+export { ContextComponent } from "./ContextComponent";
diff --git a/runner/src/server/plugins/engine/helpers.ts b/runner/src/server/plugins/engine/helpers.ts
index 4bc1a20245..456ffd4f07 100644
--- a/runner/src/server/plugins/engine/helpers.ts
+++ b/runner/src/server/plugins/engine/helpers.ts
@@ -1,5 +1,7 @@
import { RelativeUrl } from "./feedback";
import { HapiRequest, HapiResponseToolkit } from "server/types";
+import { reach } from "@hapi/hoek";
+import _ from "lodash";
export const feedbackReturnInfoKey = "f_t";
@@ -79,3 +81,30 @@ export function redirectTo(
export const idFromFilename = (filename: string) => {
return filename.replace(/govsite\.|\.json|/gi, "");
};
+
+export function getValidStateFromQueryParameters(
+ prePopFields: Record,
+ queryParameters: Record,
+ state: Record = {}
+) {
+ return Object.entries(queryParameters).reduce>(
+ (acc, [key, value]) => {
+ const prePopField = reach(prePopFields, key);
+ const stateValue = reach(state, key);
+ if (
+ !prePopField ||
+ (stateValue && !prePopField.allowPrePopulationOverwrite)
+ ) {
+ return acc;
+ }
+
+ const result = prePopField.schema.validate(value);
+ if (result.error) {
+ return acc;
+ }
+ _.set(acc, key, value);
+ return acc;
+ },
+ {}
+ );
+}
diff --git a/runner/src/server/plugins/engine/models/FormModel.feeOptions.ts b/runner/src/server/plugins/engine/models/FormModel.feeOptions.ts
new file mode 100644
index 0000000000..374f0ff416
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/FormModel.feeOptions.ts
@@ -0,0 +1,31 @@
+import { FormDefinition } from "@xgovformbuilder/model";
+
+export const DEFAULT_FEE_OPTIONS: FormDefinition["feeOptions"] = {
+ /**
+ * If a payment is required, but the user fails, allow the user to skip payment
+ * and submit the form. this is the default behaviour.
+ *
+ * Any versions AFTER (and not including) v3.25.61-rc.920 allows this behaviour
+ * to be configurable. If you do not want payment to be skippable, set
+ * `allowSubmissionWithoutPayment: false`
+ */
+ allowSubmissionWithoutPayment: true,
+
+ /**
+ * The maximum number of times a user can attempt to pay before the form is auto submitted.
+ * There is no limit when allowSubmissionWithoutPayment is false. (The user can retry as many times as they like).
+ */
+ maxAttempts: 3,
+
+ /**
+ * A supplementary error message (`customPayErrorMessage`) may also be configured if allowSubmissionWithoutPayment is false.
+ */
+ // customPayErrorMessage: "Custom error message",
+
+ /**
+ * Shows a link (button) below the "Submit and pay" button on the summary page. Clicking this will take the user to a page
+ * that provides additional messaging, you can warn the user that this may delay their application for example.
+ * allowSubmissionWithoutPayment must be true for this to be shown.
+ */
+ showPaymentSkippedWarningPage: false,
+};
diff --git a/runner/src/server/plugins/engine/models/FormModel.ts b/runner/src/server/plugins/engine/models/FormModel.ts
index 1baf9d42e2..d2cde89234 100644
--- a/runner/src/server/plugins/engine/models/FormModel.ts
+++ b/runner/src/server/plugins/engine/models/FormModel.ts
@@ -12,9 +12,16 @@ import {
} from "@xgovformbuilder/model";
import { FormSubmissionState } from "../types";
-import { PageControllerBase, getPageController } from "../pageControllers";
+import {
+ PageControllerBase,
+ getPageController,
+ SummaryPageController,
+} from "../pageControllers";
import { PageController } from "../pageControllers/PageController";
import { ExecutableCondition } from "server/plugins/engine/models/types";
+import { DEFAULT_FEE_OPTIONS } from "server/plugins/engine/models/FormModel.feeOptions";
+import { ComponentCollection } from "server/plugins/engine/components";
+import { ContextComponentCollection } from "server/plugins/engine/components/ContextComponentCollection";
class EvaluationContext {
constructor(conditions, value) {
@@ -47,11 +54,16 @@ export class FormModel {
/** the id of the form used for the first url parameter eg localhost:3009/test */
basePath: string;
conditions: Record | {};
+ fieldsForContext: ContextComponentCollection;
+ fieldsForPrePopulation: Record;
pages: any;
startPage: any;
+ feeOptions: FormDefinition["feeOptions"];
+ specialPages: FormDefinition["specialPages"];
+
constructor(def, options) {
- const result = Schema.validate(def, { abortEarly: false });
+ const result = Schema.validate(def, { abortEarly: false, convert: false });
if (result.error) {
throw result.error;
@@ -98,10 +110,14 @@ export class FormModel {
const condition = this.makeCondition(conditionDef);
this.conditions[condition.name] = condition;
});
+ this.fieldsForContext = new ContextComponentCollection(this);
+ this.fieldsForPrePopulation = {};
// @ts-ignore
this.pages = def.pages.map((pageDef) => this.makePage(pageDef));
this.startPage = this.pages.find((page) => page.path === def.startPage);
+ this.specialPages = def.specialPages;
+ this.feeOptions = { ...DEFAULT_FEE_OPTIONS, ...def.feeOptions };
}
/**
@@ -239,4 +255,28 @@ export class FormModel {
getList(name: string): List | [] {
return this.lists.find((list) => list.name === name) ?? [];
}
+
+ getContextState(state: FormSubmissionState) {
+ return this.fieldsForContext.getFormDataFromState(state);
+ }
+
+ getRelevantPages(state: FormSubmissionState) {
+ let nextPage = this.startPage;
+ const relevantPages: any[] = [];
+ let endPage = null;
+
+ while (nextPage != null) {
+ if (nextPage.hasFormComponents) {
+ relevantPages.push(nextPage);
+ } else if (
+ !nextPage.hasNext &&
+ !(nextPage instanceof SummaryPageController)
+ ) {
+ endPage = nextPage;
+ }
+ nextPage = nextPage.getNextPage(state, true);
+ }
+
+ return { relevantPages, endPage };
+ }
}
diff --git a/runner/src/server/plugins/engine/models/SummaryViewModel.ts b/runner/src/server/plugins/engine/models/SummaryViewModel.ts
index e843b8eeec..bc63a18a8a 100644
--- a/runner/src/server/plugins/engine/models/SummaryViewModel.ts
+++ b/runner/src/server/plugins/engine/models/SummaryViewModel.ts
@@ -4,18 +4,12 @@ import { FormModel } from "./FormModel";
import { feedbackReturnInfoKey, redirectUrl } from "../helpers";
import { decodeFeedbackContextInfo } from "../feedback";
import { webhookSchema } from "server/schemas/webhookSchema";
-import { SummaryPageController } from "../pageControllers";
import { FormSubmissionState } from "../types";
import { FEEDBACK_CONTEXT_ITEMS, WebhookData } from "./types";
-import {
- EmailModel,
- FeesModel,
- NotifyModel,
- WebhookModel,
-} from "server/plugins/engine/models/submission";
-import { FormDefinition, isMultipleApiKey } from "@xgovformbuilder/model";
+import { FeesModel } from "server/plugins/engine/models/submission";
import { HapiRequest } from "src/server/types";
import { InitialiseSessionOptions } from "server/plugins/initialiseSession/types";
+import { Outputs } from "server/plugins/engine/models/submission/Outputs";
/**
* TODO - extract submission behaviour dependencies from the viewmodel
@@ -56,10 +50,9 @@ export class SummaryViewModel {
backLink?: string;
_outputs: any; // TODO
- _payApiKey: FormDefinition["payApiKey"];
_webhookData: WebhookData | undefined;
callback?: InitialiseSessionOptions;
-
+ showPaymentSkippedWarningPage: boolean = false;
constructor(
pageTitle: string,
model: FormModel,
@@ -69,14 +62,13 @@ export class SummaryViewModel {
this.pageTitle = pageTitle;
this.name = model.name;
this.backLink = state?.progress?.[state?.progress.length - 1];
- const { relevantPages, endPage } = this.getRelevantPages(model, state);
+ const { relevantPages, endPage } = model.getRelevantPages(state);
const details = this.summaryDetails(request, model, state, relevantPages);
const { def } = model;
// @ts-ignore
this.declaration = def.declaration;
// @ts-ignore
this.skipSummary = def.skipSummary;
- this._payApiKey = def.payApiKey;
this.endPage = endPage;
this.feedbackLink =
def.feedback?.url ??
@@ -95,12 +87,10 @@ export class SummaryViewModel {
this.processErrors(result, details);
} else {
this.fees = FeesModel(model, state);
- this._webhookData = WebhookModel(
- relevantPages,
- details,
- model,
- this.fees
- );
+ const outputs = new Outputs(model, state);
+
+ // TODO: move to controller
+ this._webhookData = outputs.webhookData;
this._webhookData = this.addFeedbackSourceDataToWebhook(
this._webhookData,
model,
@@ -112,35 +102,8 @@ export class SummaryViewModel {
* Skip outputs if this is a callback
*/
if (def.outputs && !state.callback) {
- this._outputs = def.outputs.map((output) => {
- switch (output.type) {
- case "notify":
- return {
- type: "notify",
- outputData: NotifyModel(
- model,
- output.outputConfiguration,
- state
- ),
- };
- case "email":
- return {
- type: "email",
- outputData: EmailModel(
- model,
- output.outputConfiguration,
- this._webhookData
- ),
- };
- case "webhook":
- return {
- type: "webhook",
- outputData: { url: output.outputConfiguration.url },
- };
- default:
- return {};
- }
- });
+ // TODO: move to controller
+ this._outputs = outputs.outputs;
}
}
@@ -149,6 +112,9 @@ export class SummaryViewModel {
this.state = state;
this.value = result.value;
this.callback = state.callback;
+ const { feeOptions } = model;
+ this.showPaymentSkippedWarningPage =
+ feeOptions.showPaymentSkippedWarningPage ?? false;
}
private processErrors(result, details) {
@@ -294,26 +260,6 @@ export class SummaryViewModel {
return details;
}
- private getRelevantPages(model: FormModel, state: FormSubmissionState) {
- let nextPage = model.startPage;
- const relevantPages: any[] = [];
- let endPage = null;
-
- while (nextPage != null) {
- if (nextPage.hasFormComponents) {
- relevantPages.push(nextPage);
- } else if (
- !nextPage.hasNext &&
- !(nextPage instanceof SummaryPageController)
- ) {
- endPage = nextPage;
- }
- nextPage = nextPage.getNextPage(state, true);
- }
-
- return { relevantPages, endPage };
- }
-
get validatedWebhookData() {
const result = webhookSchema.validate(this._webhookData, {
abortEarly: false,
@@ -346,16 +292,6 @@ export class SummaryViewModel {
set outputs(value) {
this._outputs = value;
}
-
- get payApiKey() {
- if (isMultipleApiKey(this._payApiKey)) {
- return config.apiEnv === "production"
- ? this._payApiKey.production ?? this._payApiKey.test
- : this._payApiKey.test ?? this._payApiKey.production;
- }
- return this._payApiKey;
- }
-
/**
* If a declaration is defined, add this to {@link this._webhookData} as a question has answered `true` to
*/
@@ -463,6 +399,7 @@ function Item(
type: component.type,
title: component.title,
dataType: component.dataType,
+ immutable: component.options.disableChangingFromSummary,
};
return item;
diff --git a/runner/src/server/plugins/engine/models/submission/EmailModel.ts b/runner/src/server/plugins/engine/models/submission/EmailModel.ts
index e378a5439c..20a5b7ebbe 100644
--- a/runner/src/server/plugins/engine/models/submission/EmailModel.ts
+++ b/runner/src/server/plugins/engine/models/submission/EmailModel.ts
@@ -1,11 +1,18 @@
import { FormModel } from "server/plugins/engine/models";
+import { TEmailModel } from "./types";
import config from "server/config";
+import { EmailOutputConfiguration } from "@xgovformbuilder/model";
+import { WebhookData } from "server/plugins/engine/models/types";
const { notifyTemplateId, notifyAPIKey } = config;
/**
* returns an object used for sending email requests. Used by {@link SummaryViewModel}
*/
-export function EmailModel(model: FormModel, outputConfiguration, webhookData) {
+export function EmailModel(
+ model: FormModel,
+ outputConfiguration: EmailOutputConfiguration,
+ webhookData: WebhookData
+): TEmailModel {
const data: string[] = [];
webhookData?.questions?.forEach((question) => {
diff --git a/runner/src/server/plugins/engine/models/submission/FeesModel.ts b/runner/src/server/plugins/engine/models/submission/FeesModel.ts
index 1c65e5b13d..65864328e6 100644
--- a/runner/src/server/plugins/engine/models/submission/FeesModel.ts
+++ b/runner/src/server/plugins/engine/models/submission/FeesModel.ts
@@ -1,7 +1,7 @@
import { FormModel } from "server/plugins/engine/models";
import { FormSubmissionState } from "server/plugins/engine/types";
import { reach } from "hoek";
-import { Fee } from "@xgovformbuilder/model";
+import { Fee, AdditionalReportingColumn } from "@xgovformbuilder/model";
import { FeeDetails } from "server/services/payService";
export type FeesModel = {
@@ -9,6 +9,9 @@ export type FeesModel = {
total: number;
prefixes: string[];
referenceFormat?: string;
+ reportingColumns?: {
+ [key: string]: any;
+ };
};
function feesAsFeeDetails(
@@ -46,22 +49,54 @@ export function FeesModel(
return undefined;
}
- const details = feesAsFeeDetails(applicableFees, state);
+ const columnsConfig = model.feeOptions?.additionalReportingColumns;
+ const reportingColumns = ReportingColumns(columnsConfig, state);
+ const details = feesAsFeeDetails(applicableFees, state);
return details.reduce(
- (previous: FeesModel, fee: FeeDetails) => {
+ (acc: FeesModel, fee: FeeDetails) => {
const { amount, multiplyBy = 1, prefix = "" } = fee;
- return {
- ...previous,
- total: previous.total + amount * multiplyBy,
- prefixes: [...previous.prefixes, prefix].filter((p) => p),
- };
+
+ acc.total = acc.total + amount * multiplyBy;
+ acc.prefixes = [...acc.prefixes, prefix].filter((p) => p);
+
+ return acc;
},
{
details,
total: 0,
prefixes: [],
- referenceFormat: model.def.paymentReferenceFormat ?? "",
+ referenceFormat:
+ model.feeOptions?.paymentReferenceFormat ??
+ model.def.paymentReferenceFormat ??
+ "",
+ ...(reportingColumns && { reportingColumns }),
}
);
}
+
+/**
+ * Creates a GOV.UK metadata object (reporting columns) to send in the payment creation.
+ */
+export function ReportingColumns(
+ reportingColumns: FormModel["feeOptions"]["additionalReportingColumns"],
+ state: FormSubmissionState
+): FeesModel["reportingColumns"] {
+ if (!reportingColumns) {
+ return;
+ }
+
+ return reportingColumns.reduce((prev, curr) => {
+ if (curr.fieldPath) {
+ const stateValue = reach(state, curr.fieldPath);
+ if (!stateValue) {
+ return prev;
+ }
+ prev[curr.columnName] = stateValue;
+ }
+ if (curr.staticValue) {
+ prev[curr.columnName] = curr.staticValue;
+ }
+ return prev;
+ }, {});
+}
diff --git a/runner/src/server/plugins/engine/models/submission/NotifyModel.ts b/runner/src/server/plugins/engine/models/submission/NotifyModel.ts
index 70052bb630..f005f432fc 100644
--- a/runner/src/server/plugins/engine/models/submission/NotifyModel.ts
+++ b/runner/src/server/plugins/engine/models/submission/NotifyModel.ts
@@ -1,19 +1,26 @@
import { FormModel } from "server/plugins/engine/models";
import { FormSubmissionState } from "server/plugins/engine/types";
import { reach } from "hoek";
-import { NotifyOutputConfiguration } from "@xgovformbuilder/model";
+import { NotifyOutputConfiguration, List } from "@xgovformbuilder/model";
+import { TNotifyModel } from "./types";
-export type NotifyModel = Omit<
- NotifyOutputConfiguration,
- "emailField" | "replyToConfiguration" | "personalisation"
-> & {
- emailAddress: string;
- emailReplyToId?: string;
- personalisation: {
- [key: string]: string | boolean;
- };
+const parseListAsNotifyTemplate = (
+ list: List,
+ model: FormModel,
+ state: FormSubmissionState
+) => {
+ return `${list.items
+ .filter((item) => checkItemIsValid(model, state, item.condition))
+ .map((item) => `* ${item.value}\n`)
+ .join("")}`;
};
+const checkItemIsValid = (
+ model: FormModel,
+ state: FormSubmissionState,
+ conditionName
+) => model.conditions[conditionName]?.fn?.(state) ?? true;
+
/**
* returns an object used for sending GOV.UK notify requests Used by {@link SummaryViewModel} {@link NotifyService}
*/
@@ -21,23 +28,51 @@ export function NotifyModel(
model: FormModel,
outputConfiguration: NotifyOutputConfiguration,
state: FormSubmissionState
-): NotifyModel {
+): TNotifyModel {
const {
addReferencesToPersonalisation,
apiKey,
emailField,
personalisation: personalisationConfiguration,
+ personalisationFieldCustomisation = {},
emailReplyToIdConfiguration,
+ escapeURLs = false,
templateId,
} = outputConfiguration;
// @ts-ignore - eslint does not report this as an error, only tsc
const personalisation: NotifyModel["personalisation"] = personalisationConfiguration.reduce(
(acc, curr) => {
- const condition = model.conditions[curr];
+ let value, listValue, condition;
+
+ const possibleFields = [
+ curr,
+ ...(personalisationFieldCustomisation?.[curr] ?? []),
+ ];
+ //iterate through each field to find the value to use
+ possibleFields.forEach((field) => {
+ value ??= reach(state, field);
+ listValue ??= model.lists.find((list) => list.name === field);
+ condition ??= model.conditions[curr];
+ });
+
+ let personalisationValue;
+
+ if (condition) {
+ personalisationValue ??= condition.fn?.(state);
+ }
+ if (listValue) {
+ personalisationValue ??= parseListAsNotifyTemplate(
+ listValue,
+ model,
+ state
+ );
+ }
+ personalisationValue ??= value;
+
return {
...acc,
- [curr]: condition ? condition.fn(state) : reach(state, curr),
+ [curr]: personalisationValue,
};
},
{}
@@ -62,6 +97,7 @@ export function NotifyModel(
emailAddress: reach(state, emailField) as string,
apiKey: apiKey,
addReferencesToPersonalisation,
+ escapeURLs,
...(emailReplyToId && { emailReplyToId }),
};
}
diff --git a/runner/src/server/plugins/engine/models/submission/Outputs.ts b/runner/src/server/plugins/engine/models/submission/Outputs.ts
new file mode 100644
index 0000000000..cfc7d24186
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/submission/Outputs.ts
@@ -0,0 +1,62 @@
+import { FormModel } from "server/plugins/engine/models";
+import { FormSubmissionState } from "server/plugins/engine/types";
+import {
+ EmailModel,
+ WebhookModel,
+ NotifyModel,
+} from "server/plugins/engine/models/submission";
+import { WebhookData } from "server/plugins/engine/models/types";
+import {
+ EmailOutputConfiguration,
+ NotifyOutputConfiguration,
+ OutputType,
+ WebhookOutputConfiguration,
+} from "@xgovformbuilder/model";
+import { OutputData } from "server/plugins/engine/models/submission/types";
+
+export class Outputs {
+ webhookData: WebhookData;
+ outputs: (OutputData | unknown)[];
+
+ constructor(model: FormModel, state: FormSubmissionState) {
+ this.webhookData = WebhookModel(model, state);
+
+ const outputDefs = model.def.outputs;
+ this.outputs = outputDefs.map((output) => {
+ switch (output.type) {
+ case "notify":
+ /**
+ * Typescript does not support nested type discrimination {@link https://github.com/microsoft/TypeScript/issues/18758}
+ */
+ const notifyOutputConfiguration = output.outputConfiguration as NotifyOutputConfiguration;
+ return {
+ type: OutputType.Notify,
+ outputData: NotifyModel(model, notifyOutputConfiguration, state),
+ };
+ case "email":
+ const emailOutputConfiguration = output.outputConfiguration as EmailOutputConfiguration;
+ return {
+ type: OutputType.Email,
+ outputData: EmailModel(
+ model,
+ emailOutputConfiguration,
+ this.webhookData
+ ),
+ };
+ case "webhook":
+ const webhookOutputConfiguration = output.outputConfiguration as WebhookOutputConfiguration;
+ return {
+ type: OutputType.Webhook,
+ outputData: {
+ url: webhookOutputConfiguration.url,
+ sendAdditionalPayMetadata:
+ webhookOutputConfiguration.sendAdditionalPayMetadata,
+ allowRetry: webhookOutputConfiguration.allowRetry,
+ },
+ };
+ default:
+ return {} as unknown;
+ }
+ });
+ }
+}
diff --git a/runner/src/server/plugins/engine/models/submission/WebhookModel.ts b/runner/src/server/plugins/engine/models/submission/WebhookModel.ts
index e9320a6724..69e4e7b258 100644
--- a/runner/src/server/plugins/engine/models/submission/WebhookModel.ts
+++ b/runner/src/server/plugins/engine/models/submission/WebhookModel.ts
@@ -1,77 +1,104 @@
-import { DetailItem } from "../types";
import { format } from "date-fns";
import config from "server/config";
+import { FormModel } from "server/plugins/engine/models";
+import { FormSubmissionState } from "server/plugins/engine/types";
+import { FeesModel } from "server/plugins/engine/models/submission/FeesModel";
+import { FormComponent } from "server/plugins/engine/components";
+import { Field } from "server/schemas/types";
+import { PageControllerBase } from "server/plugins/engine/pageControllers";
+import { SelectionControlField } from "server/plugins/engine/components/SelectionControlField";
+import nunjucks from "nunjucks";
+export function WebhookModel(model: FormModel, state: FormSubmissionState) {
+ let englishName = `${config.serviceName} ${model.basePath}`;
-function answerFromDetailItem(item) {
- switch (item.dataType) {
- case "list":
- return item.rawValue;
- case "date":
- return format(new Date(item.rawValue), "yyyy-MM-dd");
- case "monthYear":
- const [month, year] = Object.values(item.rawValue);
- return format(new Date(`${year}-${month}-1`), "yyyy-MM");
- default:
- return item.value;
+ if (model.name) {
+ englishName = model.name.en ?? model.name;
}
-}
-function detailItemToField(item: DetailItem) {
- return {
- key: item.name,
- title: item.title,
- type: item.dataType,
- answer: answerFromDetailItem(item),
- };
-}
+ let questions;
-export function WebhookModel(relevantPages, details, model, fees) {
- const questions = relevantPages?.map((page) => {
- const isRepeatable = !!page.repeatField;
+ const { relevantPages } = model.getRelevantPages(state);
- const itemsForPage = details.flatMap((detail) =>
- detail.items.filter((item) => item.path === page.path)
- );
+ questions = relevantPages.map((page) => pagesToQuestions(page, state));
+ const fees = FeesModel(model, state);
- const detailItems = isRepeatable
- ? [itemsForPage].map((item) => ({ ...item, isRepeatable }))
- : itemsForPage;
+ return {
+ metadata: model.def.metadata,
+ name: englishName,
+ questions: questions,
+ ...(!!fees && { fees }),
+ };
+}
- let index = 0;
- const fields = detailItems.flatMap((item, i) => {
- item.isRepeatable ? (index = i) : 0;
- const fields = [detailItemToField(item)];
+function createToFieldsMap(state: FormSubmissionState) {
+ return function (component: FormComponent | SelectionControlField): Field {
+ // @ts-ignore - This block of code should not be hit since childrenCollection no
+ if (component.items?.childrenCollection?.formItems) {
+ const toField = createToFieldsMap(state);
/**
* This is currently deprecated whilst GDS fix a known issue with accessibility and conditionally revealed fields
*/
- const nestedItems = item?.items?.childrenCollection.formItems;
- nestedItems &&
- fields.push(nestedItems.map((item) => detailItemToField(item)));
-
- return fields;
- });
+ // @ts-ignore
+ const nestedComponent = component?.items?.childrenCollection.formItems;
+ const nestedFields = nestedComponent?.map(toField);
+ return nestedFields;
+ }
return {
- category: page.section?.name,
- question:
- page.title?.en ??
- page.title ??
- page.components.formItems.map((item) => item.title),
- fields,
- index,
+ key: component.name,
+ title: component.title,
+ type: component.dataType,
+ answer: fieldAnswerFromComponent(component, state),
};
- });
+ };
+}
- // default name if no name is provided
- let englishName = `${config.serviceName} ${model.basePath}`;
- if (model.name) {
- englishName = model.name.en ?? model.name;
+function pagesToQuestions(
+ page: PageControllerBase,
+ state: FormSubmissionState,
+ index = 0
+) {
+ // TODO - index should come from the current iteration of the section.
+
+ let sectionState = state;
+ if (page.section) {
+ sectionState = state[page.section.name];
}
+
+ const toFields = createToFieldsMap(sectionState);
+ const components = page.components.formItems;
+
+ const pageTitle = nunjucks.renderString(page.title.en ?? page.title, {
+ ...state,
+ });
+
return {
- metadata: model.def.metadata,
- name: englishName,
- questions: questions,
- ...(!!fees && { fees }),
+ category: page.section?.name,
+ question: pageTitle,
+ fields: components.flatMap(toFields),
+ index,
};
}
+
+function fieldAnswerFromComponent(
+ component: FormComponent,
+ state: FormSubmissionState
+) {
+ if (!component) {
+ return;
+ }
+ const rawValue = state[component.name];
+
+ switch (component.dataType) {
+ case "list":
+ return rawValue;
+ case "date":
+ return format(new Date(rawValue), "yyyy-MM-dd");
+ case "monthYear":
+ const [month, year] = Object.values(rawValue);
+ return format(new Date(`${year}-${month}-1`), "yyyy-MM");
+ default:
+ return component.getDisplayStringFromState(state);
+ }
+}
diff --git a/runner/src/server/plugins/engine/models/submission/__tests__/FeesModel.test.ts b/runner/src/server/plugins/engine/models/submission/__tests__/FeesModel.test.ts
index 5b01461fa9..fef8f9d154 100644
--- a/runner/src/server/plugins/engine/models/submission/__tests__/FeesModel.test.ts
+++ b/runner/src/server/plugins/engine/models/submission/__tests__/FeesModel.test.ts
@@ -1,4 +1,4 @@
-import { FeesModel } from "./../FeesModel";
+import { FeesModel, ReportingColumns } from "./../FeesModel";
import * as Code from "@hapi/code";
import * as Lab from "@hapi/lab";
const { expect } = Code;
@@ -10,12 +10,12 @@ import { FormModel } from "server/plugins/engine/models";
suite("FeesModel", () => {
test("returns correct FeesModel", () => {
- const c = {
+ const state = {
caz: "2",
};
const form = new FormModel(json, {});
- const model = FeesModel(form, c);
+ const model = FeesModel(form, state);
expect(model).to.equal({
details: [
{ description: "Bristol tax", amount: 5000, condition: "dFQTyf" },
@@ -26,4 +26,116 @@ suite("FeesModel", () => {
referenceFormat: "FCDO-{{DATE}}",
});
});
+ test("returns correct payment reference format when a peyment reference is supplied in the feeOptions", () => {
+ const state = {
+ caz: "2",
+ };
+ const newJson = {
+ ...json,
+ feeOptions: {
+ paymentReferenceFormat: "FCDO2-{{DATE}}",
+ },
+ };
+ const form = new FormModel(newJson, {});
+ const model = FeesModel(form, state);
+ expect(model).to.equal({
+ details: [
+ { description: "Bristol tax", amount: 5000, condition: "dFQTyf" },
+ { description: "car tax", amount: 5000 },
+ ],
+ total: 10000,
+ prefixes: [],
+ referenceFormat: "FCDO2-{{DATE}}",
+ });
+ });
+ test("returns correct payment reference format when a peyment reference is supplied in the feeOptions", () => {
+ const newJson = {
+ ...json,
+ feeOptions: {
+ paymentReferenceFormat: "FCDO2-{{DATE}}",
+ additionalReportingColumns: [
+ {
+ columnName: "zone",
+ fieldPath: "caz",
+ },
+ ],
+ },
+ };
+ const form = new FormModel(newJson, {});
+
+ const state = {
+ caz: "2",
+ };
+
+ const model = FeesModel(form, state);
+ expect(model).to.equal({
+ details: [
+ { description: "Bristol tax", amount: 5000, condition: "dFQTyf" },
+ { description: "car tax", amount: 5000 },
+ ],
+ total: 10000,
+ prefixes: [],
+ referenceFormat: "FCDO2-{{DATE}}",
+ reportingColumns: {
+ zone: "2",
+ },
+ });
+ });
+});
+
+suite("ReportingColumns", () => {
+ const additionalReportingColumns = [
+ {
+ columnName: "country",
+ fieldPath: "beforeYouStart.country",
+ },
+ {
+ columnName: "post",
+ fieldPath: "post",
+ },
+ {
+ columnName: "service",
+ staticValue: "fee 11",
+ },
+ ];
+
+ test("Returns the correct metadata for GOV.UK Pay", () => {
+ expect(
+ ReportingColumns(additionalReportingColumns, {
+ beforeYouStart: {
+ country: "Italy",
+ },
+ post: "British Embassy Rome",
+ })
+ ).to.equal({
+ country: "Italy",
+ post: "British Embassy Rome",
+ service: "fee 11",
+ });
+ });
+
+ test("Does not add a reporting column when the state value is missing for a nested state value", () => {
+ expect(
+ ReportingColumns(additionalReportingColumns, { post: "A" })
+ ).to.equal({ post: "A", service: "fee 11" });
+ });
+
+ test("Does not add a reporting column when the state value is missing for an un-nested", () => {
+ expect(
+ ReportingColumns(additionalReportingColumns, {
+ beforeYouStart: {
+ country: "Italy",
+ },
+ })
+ ).to.equal({
+ country: "Italy",
+ service: "fee 11",
+ });
+ });
+
+ test("Adds static values", () => {
+ expect(ReportingColumns(additionalReportingColumns, {})).to.equal({
+ service: "fee 11",
+ });
+ });
});
diff --git a/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.json b/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.json
new file mode 100644
index 0000000000..fcf8da1ca4
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.json
@@ -0,0 +1,104 @@
+{
+ "metadata": {},
+ "startPage": "/first-page",
+ "pages": [
+ {
+ "title": "First page",
+ "path": "/first-page",
+ "components": [
+ {
+ "name": "SWJtVi",
+ "options": {},
+ "type": "YesNoField",
+ "title": "Should item 1 be shown?"
+ },
+ {
+ "name": "dxWjPr",
+ "options": {},
+ "type": "YesNoField",
+ "title": "Should item 2 be shown?"
+ },
+ {
+ "name": "TZOHRn",
+ "options": {},
+ "type": "EmailAddressField",
+ "title": "Email address"
+ }
+ ],
+ "next": [{ "path": "/summary" }]
+ },
+ {
+ "title": "Summary",
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "components": []
+ }
+ ],
+ "lists": [
+ {
+ "title": "New list",
+ "name": "wVUZJW",
+ "type": "string",
+ "items": [
+ { "text": "Item 1", "value": "Item 1", "condition": "KAOicj" },
+ { "text": "Item 2", "value": "Item 2", "condition": "vzzqjG" }
+ ]
+ }
+ ],
+ "sections": [],
+ "conditions": [
+ {
+ "displayName": "Item 1 should be shown",
+ "name": "KAOicj",
+ "value": {
+ "name": "Item 1 should be shown",
+ "conditions": [
+ {
+ "field": {
+ "name": "SWJtVi",
+ "type": "YesNoField",
+ "display": "Should item 1 be shown?"
+ },
+ "operator": "is",
+ "value": { "type": "Value", "value": "true", "display": "true" }
+ }
+ ]
+ }
+ },
+ {
+ "displayName": "Item 2 should be shown",
+ "name": "vzzqjG",
+ "value": {
+ "name": "Item 2 should be shown",
+ "conditions": [
+ {
+ "field": {
+ "name": "dxWjPr",
+ "type": "YesNoField",
+ "display": "Should item 2 be shown?"
+ },
+ "operator": "is",
+ "value": { "type": "Value", "value": "true", "display": "true" }
+ }
+ ]
+ }
+ }
+ ],
+ "fees": [],
+ "outputs": [
+ {
+ "name": "iykabp",
+ "title": "test",
+ "type": "notify",
+ "outputConfiguration": {
+ "personalisation": ["wVUZJW"],
+ "templateId": "test",
+ "apiKey": "test",
+ "emailField": "TZOHRn",
+ "addReferencesToPersonalisation": false
+ }
+ }
+ ],
+ "version": 2,
+ "skipSummary": false
+}
diff --git a/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.ts b/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.ts
new file mode 100644
index 0000000000..7d84ae3cd6
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/submission/__tests__/NotifyModel.test.ts
@@ -0,0 +1,56 @@
+import { NotifyModel } from "../NotifyModel";
+import * as Code from "@hapi/code";
+import * as Lab from "@hapi/lab";
+const { expect } = Code;
+const lab = Lab.script();
+exports.lab = lab;
+const { suite, test } = lab;
+import json from "./NotifyModel.test.json";
+import { FormModel } from "server/plugins/engine/models";
+import { FormSubmissionState } from "server/plugins/engine/types";
+
+const testFormSubmission = (state: FormSubmissionState) => {
+ const notifyOutputConfiguration = {
+ apiKey: "test",
+ templateId: "test",
+ emailField: "TZOHRn",
+ personalisation: ["wVUZJW"],
+ };
+
+ const form = new FormModel(json, {});
+ return NotifyModel(form, notifyOutputConfiguration, state);
+};
+
+suite("NotifyModel", () => {
+ test("returns correct personalisation when a list is passed in and both conditions are satisfied", () => {
+ const state: FormSubmissionState = {
+ SWJtVi: true,
+ dxWjPr: true,
+ TZOHRn: "test@test.com",
+ };
+ const model = testFormSubmission(state);
+ expect(model.personalisation["wVUZJW"]).to.equal(`* Item 1\n* Item 2\n`);
+ });
+ test("returns correct personalisation when a list is passed in and the second condition is satisfied", () => {
+ const state: FormSubmissionState = {
+ SWJtVi: true,
+ dxWjPr: false,
+ TZOHRn: "test@test.com",
+ };
+
+ const model = testFormSubmission(state);
+
+ expect(model.personalisation["wVUZJW"]).to.equal(`* Item 1\n`);
+ });
+ test("returns an empty string when a list is passed in and no conditions are satisfied", () => {
+ const state: FormSubmissionState = {
+ SWJtVi: false,
+ dxWjPr: false,
+ TZOHRn: "test@test.com",
+ };
+
+ const model = testFormSubmission(state);
+
+ expect(model.personalisation["wVUZJW"]).to.equal("");
+ });
+});
diff --git a/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.json b/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.json
new file mode 100644
index 0000000000..b01ed42917
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.json
@@ -0,0 +1,553 @@
+{
+ "startPage": "/start",
+ "pages": [
+ {
+ "title": "Start",
+ "path": "/start",
+ "components": [],
+ "next": [
+ {
+ "path": "/uk-passport"
+ }
+ ],
+ "controller": "./pages/start.js"
+ },
+ {
+ "path": "/uk-passport",
+ "components": [
+ {
+ "type": "YesNoField",
+ "name": "ukPassport",
+ "title": "Do you have a UK passport?",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "section": "checkBeforeYouStart",
+ "next": [
+ {
+ "path": "/how-many-people"
+ },
+ {
+ "path": "/no-uk-passport",
+ "condition": "doesntHaveUKPassport"
+ }
+ ],
+ "title": "Do you have a UK passport?"
+ },
+ {
+ "path": "/no-uk-passport",
+ "title": "You're not eligible for this service",
+ "components": [
+ {
+ "type": "Para",
+ "content": "If you still think you're eligible please contact the Foreign and Commonwealth Office.",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": []
+ },
+ {
+ "path": "/how-many-people",
+ "section": "applicantDetails",
+ "components": [
+ {
+ "options": {
+ "classes": "govuk-input--width-10",
+ "required": true
+ },
+ "type": "SelectField",
+ "name": "numberOfApplicants",
+ "title": "How many applicants are there?",
+ "list": "numberOfApplicants"
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-one"
+ }
+ ],
+ "title": "How many applicants are there?"
+ },
+ {
+ "path": "/applicant-one",
+ "title": "Applicant 1",
+ "section": "applicantOneDetails",
+ "components": [
+ {
+ "type": "Para",
+ "content": "Provide the details as they appear on your passport.",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "firstName",
+ "title": "First name",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "options": {
+ "required": false,
+ "optionalText": false
+ },
+ "type": "TextField",
+ "name": "middleName",
+ "title": "Middle name",
+ "hint": "If you have a middle name on your passport you must include it here",
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "lastName",
+ "title": "Surname",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-one-address"
+ }
+ ]
+ },
+ {
+ "path": "/applicant-one-address",
+ "section": "applicantOneDetails",
+ "components": [
+ {
+ "type": "UkAddressField",
+ "name": "address",
+ "title": "Address",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-two",
+ "condition": "moreThanOneApplicant"
+ },
+ {
+ "path": "/which-languages-do-you-speak"
+ }
+ ],
+ "title": "Address"
+ },
+ {
+ "path": "/applicant-two",
+ "title": "Applicant 2",
+ "section": "applicantTwoDetails",
+ "components": [
+ {
+ "type": "Para",
+ "content": "Provide the details as they appear on your passport.",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "firstName",
+ "title": "First name",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "options": {
+ "required": false,
+ "optionalText": false
+ },
+ "type": "TextField",
+ "name": "middleName",
+ "title": "Middle name",
+ "hint": "If you have a middle name on your passport you must include it here",
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "lastName",
+ "title": "Surname",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-two-address"
+ }
+ ]
+ },
+ {
+ "path": "/applicant-two-address",
+ "section": "applicantTwoDetails",
+ "components": [
+ {
+ "type": "UkAddressField",
+ "name": "address",
+ "title": "Address",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-three",
+ "condition": "moreThanTwoApplicants"
+ },
+ {
+ "path": "/which-languages-do-you-speak"
+ }
+ ],
+ "title": "Address"
+ },
+ {
+ "path": "/applicant-three",
+ "title": "Applicant 3",
+ "section": "applicantThreeDetails",
+ "components": [
+ {
+ "type": "Para",
+ "content": "Provide the details as they appear on your passport.",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "firstName",
+ "title": "First name",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "options": {
+ "required": false,
+ "optionalText": false
+ },
+ "type": "TextField",
+ "name": "middleName",
+ "title": "Middle name",
+ "hint": "If you have a middle name on your passport you must include it here",
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "lastName",
+ "title": "Surname",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-three-address"
+ }
+ ]
+ },
+ {
+ "path": "/applicant-three-address",
+ "section": "applicantThreeDetails",
+ "components": [
+ {
+ "type": "UkAddressField",
+ "name": "address",
+ "title": "Address",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-four",
+ "condition": "moreThanThreeApplicants"
+ },
+ {
+ "path": "/which-languages-do-you-speak"
+ }
+ ],
+ "title": "Address"
+ },
+ {
+ "path": "/applicant-four",
+ "title": "Applicant 4",
+ "section": "applicantFourDetails",
+ "components": [
+ {
+ "type": "Para",
+ "content": "Provide the details as they appear on your passport.",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "firstName",
+ "title": "First name",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "options": {
+ "required": false,
+ "optionalText": false
+ },
+ "type": "TextField",
+ "name": "middleName",
+ "title": "Middle name",
+ "hint": "If you have a middle name on your passport you must include it here",
+ "schema": {}
+ },
+ {
+ "type": "TextField",
+ "name": "lastName",
+ "title": "Surname",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/applicant-four-address"
+ }
+ ]
+ },
+ {
+ "path": "/applicant-four-address",
+ "section": "applicantFourDetails",
+ "components": [
+ {
+ "type": "UkAddressField",
+ "name": "address",
+ "title": "Address",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/which-languages-do-you-speak"
+ }
+ ],
+ "title": "Address"
+ },
+ {
+ "path": "/which-languages-do-you-speak",
+ "title": "Which languages do you speak?",
+ "section": "applicantDetails",
+ "controller": "RepeatingFieldPageController",
+ "options": {
+ "summaryDisplayMode": {
+ "samePage": true,
+ "separatePage": true,
+ "hideRowTitles": true
+ },
+ "customText": {
+ "separatePageTitle": "You have selected these languages"
+ }
+ },
+ "components": [
+ {
+ "name": "languagesProvided",
+ "options": {
+ "hideTitle": true
+ },
+ "list": "languages",
+ "type": "AutocompleteField",
+ "title": "Language",
+ "hint": "Start typing and select a language. Add each language separately. Include all the languages you provide a service in, apart from English.",
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/contact-details"
+ }
+ ]
+ },
+ {
+ "path": "/contact-details",
+ "section": "applicantDetails",
+ "components": [
+ {
+ "type": "TelephoneNumberField",
+ "name": "phoneNumber",
+ "title": "Phone number",
+ "hint": "If you haven't got a UK phone number, include country code",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "EmailAddressField",
+ "name": "emailAddress",
+ "title": "Your email address",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ },
+ {
+ "type": "DatePartsField",
+ "name": "contactDate",
+ "title": "Contact date",
+ "hint": "Tell us when we should contact you",
+ "options": {
+ "required": true
+ },
+ "schema": {}
+ }
+ ],
+ "next": [
+ {
+ "path": "/summary"
+ }
+ ],
+ "title": "Applicant contact details"
+ },
+ {
+ "path": "/summary",
+ "controller": "./pages/summary.js",
+ "title": "Summary",
+ "components": [],
+ "next": []
+ }
+ ],
+ "lists": [
+ {
+ "name": "numberOfApplicants",
+ "title": "Number of people",
+ "type": "number",
+ "items": [
+ {
+ "text": "1",
+ "value": 1,
+ "description": "",
+ "condition": ""
+ },
+ {
+ "text": "2",
+ "value": 2,
+ "description": "",
+ "condition": ""
+ },
+ {
+ "text": "3",
+ "value": 3,
+ "description": "",
+ "condition": ""
+ },
+ {
+ "text": "4",
+ "value": 4,
+ "description": "",
+ "condition": ""
+ }
+ ]
+ }
+ ],
+ "sections": [
+ {
+ "name": "checkBeforeYouStart",
+ "title": "Check before you start"
+ },
+ {
+ "name": "applicantDetails",
+ "title": "Applicant details"
+ },
+ {
+ "name": "applicantOneDetails",
+ "title": "Applicant 1"
+ },
+ {
+ "name": "applicantTwoDetails",
+ "title": "Applicant 2"
+ },
+ {
+ "name": "applicantThreeDetails",
+ "title": "Applicant 3"
+ },
+ {
+ "name": "applicantFourDetails",
+ "title": "Applicant 4"
+ }
+ ],
+ "phaseBanner": {},
+ "fees": [],
+ "payApiKey": "",
+ "outputs": [
+ {
+ "name": "LwQeVI",
+ "title": "Test webhook",
+ "type": "webhook",
+ "outputConfiguration": {
+ "url": "https://61bca17e-fe74-40e0-9c15-a901ad120eca.mock.pstmn.io"
+ }
+ }
+ ],
+ "declaration": "All the answers you have provided are true to the best of your knowledge.
",
+ "version": 2,
+ "conditions": [
+ {
+ "name": "hasUKPassport",
+ "displayName": "hasUKPassport",
+ "value": "checkBeforeYouStart.ukPassport==true"
+ },
+ {
+ "name": "doesntHaveUKPassport",
+ "displayName": "doesntHaveUKPassport",
+ "value": "checkBeforeYouStart.ukPassport==false"
+ },
+ {
+ "name": "moreThanOneApplicant",
+ "displayName": "moreThanOneApplicant",
+ "value": "applicantDetails.numberOfApplicants > 1"
+ },
+ {
+ "name": "moreThanTwoApplicants",
+ "displayName": "moreThanTwoApplicants",
+ "value": "applicantDetails.numberOfApplicants > 2"
+ },
+ {
+ "name": "moreThanThreeApplicants",
+ "displayName": "moreThanThreeApplicants",
+ "value": "applicantDetails.numberOfApplicants > 3"
+ }
+ ]
+}
diff --git a/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.ts b/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.ts
new file mode 100644
index 0000000000..365cd6868f
--- /dev/null
+++ b/runner/src/server/plugins/engine/models/submission/__tests__/WebhookModel.test.ts
@@ -0,0 +1,223 @@
+import * as Code from "@hapi/code";
+import * as Lab from "@hapi/lab";
+const { expect } = Code;
+const lab = Lab.script();
+exports.lab = lab;
+const { suite, test } = lab;
+import json from "./WebhookModel.test.json";
+import { FormModel, SummaryViewModel } from "server/plugins/engine/models";
+import { WebhookModel } from "server/plugins/engine/models/submission";
+const form = new FormModel(json, {});
+
+const state = {
+ progress: [],
+ checkBeforeYouStart: {
+ ukPassport: true,
+ },
+ applicantDetails: {
+ numberOfApplicants: 2,
+ phoneNumber: "123",
+ emailAddress: "a@b",
+ languagesProvided: ["fr", "it"],
+ contactDate: "2024-12-25T00:00:00.000Z",
+ },
+ applicantOneDetails: {
+ firstName: "Winston",
+ lastName: "Smith",
+ address: {
+ addressLine1: "1 Street",
+ town: "London",
+ postcode: "ec2a4ps",
+ },
+ },
+ applicantTwoDetails: {
+ firstName: "big",
+ lastName: "brother",
+ address: {
+ addressLine1: "2 Street",
+ town: "London",
+ postcode: "ec2a4ps",
+ },
+ },
+};
+
+const summaryViewModel = new SummaryViewModel(
+ "summary",
+ form,
+ { ...state },
+ {
+ query: {},
+ }
+);
+
+suite("WebhookModel", () => {
+ test("SummaryViewModel returns correct WebhookModel", () => {
+ const webhookData = summaryViewModel._webhookData;
+ expect(webhookData).to.equal(expectedWebhookData);
+ });
+
+ test("returns correct webhook model", () => {
+ expect(WebhookModel(form, state)).to.equal(expectedWebhookData);
+ });
+
+ test("templated page titles can are rendered in question", () => {
+ const modifiedForm = new FormModel(json, {});
+ const page = modifiedForm.pages.find(
+ (page) => page.title === "Applicant 1"
+ );
+ page.title =
+ "Applicant 1 (has UK passport: {{ checkBeforeYouStart.ukPassport }})";
+ const webhookModel = WebhookModel(modifiedForm, state);
+ const modifiedQuestion = webhookModel.questions.find(
+ (question) => question.question === "Applicant 1 (has UK passport: true)"
+ );
+ expect(modifiedQuestion).exists();
+ });
+});
+
+const expectedWebhookData = {
+ name: "Digital Form Builder - Runner undefined",
+ metadata: undefined,
+ questions: [
+ {
+ category: "checkBeforeYouStart",
+ question: "Do you have a UK passport?",
+ fields: [
+ {
+ key: "ukPassport",
+ title: "Do you have a UK passport?",
+ type: "list",
+ answer: true,
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantDetails",
+ question: "How many applicants are there?",
+ fields: [
+ {
+ key: "numberOfApplicants",
+ title: "How many applicants are there?",
+ type: "list",
+ answer: 2,
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantOneDetails",
+ question: "Applicant 1",
+ fields: [
+ {
+ key: "firstName",
+ title: "First name",
+ type: "text",
+ answer: "Winston",
+ },
+ {
+ key: "middleName",
+ answer: undefined,
+ title: "Middle name",
+ type: "text",
+ },
+ {
+ key: "lastName",
+ title: "Surname",
+ type: "text",
+ answer: "Smith",
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantOneDetails",
+ question: "Address",
+ fields: [
+ {
+ key: "address",
+ title: "Address",
+ type: "text",
+ answer: "1 Street, London, ec2a4ps",
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantTwoDetails",
+ question: "Applicant 2",
+ fields: [
+ {
+ key: "firstName",
+ title: "First name",
+ type: "text",
+ answer: "big",
+ },
+ {
+ key: "middleName",
+ answer: undefined,
+ title: "Middle name",
+ type: "text",
+ },
+ {
+ key: "lastName",
+ title: "Surname",
+ type: "text",
+ answer: "brother",
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantTwoDetails",
+ question: "Address",
+ fields: [
+ {
+ key: "address",
+ title: "Address",
+ type: "text",
+ answer: "2 Street, London, ec2a4ps",
+ },
+ ],
+ index: 0,
+ },
+ {
+ category: "applicantDetails",
+ fields: [
+ {
+ answer: ["fr", "it"],
+ key: "languagesProvided",
+ title: "Language",
+ type: "list",
+ },
+ ],
+ index: 0,
+ question: "Which languages do you speak?",
+ },
+ {
+ category: "applicantDetails",
+ question: "Applicant contact details",
+ fields: [
+ {
+ key: "phoneNumber",
+ title: "Phone number",
+ type: "text",
+ answer: "123",
+ },
+ {
+ key: "emailAddress",
+ title: "Your email address",
+ type: "text",
+ answer: "a@b",
+ },
+ {
+ answer: "2024-12-25",
+ key: "contactDate",
+ title: "Contact date",
+ type: "date",
+ },
+ ],
+ index: 0,
+ },
+ ],
+};
diff --git a/runner/src/server/plugins/engine/models/submission/types.ts b/runner/src/server/plugins/engine/models/submission/types.ts
index e69de29bb2..d4e7e242d9 100644
--- a/runner/src/server/plugins/engine/models/submission/types.ts
+++ b/runner/src/server/plugins/engine/models/submission/types.ts
@@ -0,0 +1,43 @@
+import { NotifyOutputConfiguration, OutputType } from "@xgovformbuilder/model";
+
+export type TNotifyModel = Omit<
+ NotifyOutputConfiguration,
+ "emailField" | "replyToConfiguration" | "personalisation"
+> & {
+ emailAddress: string;
+ emailReplyToId?: string;
+ personalisation: {
+ [key: string]: string | boolean;
+ };
+};
+
+export type TEmailModel = {
+ personalisation: {
+ formName: string;
+ formPayload: string;
+ };
+ apiKey: string;
+ templateId: string;
+ emailAddress: string;
+};
+
+type NotifyOutputData = {
+ type: OutputType.Notify;
+ outputData: TNotifyModel;
+};
+
+type EmailOutputData = {
+ type: OutputType.Email;
+ outputData: TEmailModel;
+};
+
+type WebhookOutputData = {
+ type: OutputType.Webhook;
+ outputData: {
+ url: string;
+ sendAdditionalPayMetadata?: boolean;
+ allowRetry?: boolean;
+ };
+};
+
+export type OutputData = NotifyOutputData | EmailOutputData | WebhookOutputData;
diff --git a/runner/src/server/plugins/engine/pageControllers/MultiStartPageController.ts b/runner/src/server/plugins/engine/pageControllers/MultiStartPageController.ts
new file mode 100644
index 0000000000..f7fa9cd6be
--- /dev/null
+++ b/runner/src/server/plugins/engine/pageControllers/MultiStartPageController.ts
@@ -0,0 +1,18 @@
+import { FormData, FormSubmissionErrors } from "../types";
+import { PageController } from "./PageController";
+
+export class MultiStartPageController extends PageController {
+ get viewName() {
+ return "multi-start-page";
+ }
+ getViewModel(formData: FormData, errors?: FormSubmissionErrors) {
+ const viewModel = super.getViewModel(formData, errors);
+ const { showContinueButton, startPageNavigation } = this.pageDef;
+ return {
+ ...viewModel,
+ continueButtonText: showContinueButton && this.pageDef.continueButtonText,
+ startPageNavigation,
+ isMultiStartPageController: true,
+ };
+ }
+}
diff --git a/runner/src/server/plugins/engine/pageControllers/PageController.ts b/runner/src/server/plugins/engine/pageControllers/PageController.ts
index 78bd0a0f6c..ccb871b005 100644
--- a/runner/src/server/plugins/engine/pageControllers/PageController.ts
+++ b/runner/src/server/plugins/engine/pageControllers/PageController.ts
@@ -40,7 +40,7 @@ export class PageController extends PageControllerBase {
onPreHandler: {
method: async (request: HapiRequest, h: HapiResponseToolkit) => {
const { uploadService } = request.services([]);
- return uploadService.handleUploadRequest(request, h);
+ return uploadService.handleUploadRequest(request, h, this.pageDef);
},
},
onPostHandler: {
diff --git a/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts b/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts
index 07b34efe91..518d71e87b 100644
--- a/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts
+++ b/runner/src/server/plugins/engine/pageControllers/PageControllerBase.ts
@@ -23,6 +23,8 @@ import {
} from "../types";
import { ComponentCollectionViewModel } from "../components/types";
import { format, parseISO } from "date-fns";
+import config from "server/config";
+import nunjucks from "nunjucks";
const FORM_SCHEMA = Symbol("FORM_SCHEMA");
const STATE_SCHEMA = Symbol("STATE_SCHEMA");
@@ -53,6 +55,7 @@ export class PageControllerBase {
components: ComponentCollection;
hasFormComponents: boolean;
hasConditionalFormComponents: boolean;
+ backLinkFallback?: string;
// TODO: pageDef type
constructor(model: FormModel, pageDef: { [prop: string]: any } = {}) {
@@ -68,6 +71,7 @@ export class PageControllerBase {
this.title = pageDef.title;
this.condition = pageDef.condition;
this.repeatField = pageDef.repeatField;
+ this.backLinkFallback = pageDef.backLinkFallback;
// Resolve section
this.section = model.sections?.find(
@@ -80,6 +84,20 @@ export class PageControllerBase {
(c: any) => c.conditionalComponents
);
+ const fieldsForPrePopulation = components.prePopulatedItems;
+
+ if (this.section) {
+ this.model.fieldsForPrePopulation[this.section.name] = {
+ ...(this.model.fieldsForPrePopulation[this.section.name] ?? {}),
+ ...fieldsForPrePopulation,
+ };
+ } else {
+ this.model.fieldsForPrePopulation = {
+ ...this.model.fieldsForPrePopulation,
+ ...fieldsForPrePopulation,
+ };
+ }
+
this.components = components;
this.hasFormComponents = !!components.formItems.length;
this.hasConditionalFormComponents = !!conditionalFormComponents.length;
@@ -111,7 +129,12 @@ export class PageControllerBase {
} {
let showTitle = true;
let pageTitle = this.title;
- let sectionTitle = this.section?.title;
+ if (config.allowUserTemplates) {
+ pageTitle = nunjucks.renderString(pageTitle, {
+ ...formData,
+ });
+ }
+ let sectionTitle = !this.section?.hideTitle && this.section?.title;
if (sectionTitle && iteration !== undefined) {
sectionTitle = `${sectionTitle} ${iteration}`;
}
@@ -158,9 +181,16 @@ export class PageControllerBase {
}
get next() {
- return (this.pageDef.next || [])
- .map((next: { path: string }) => {
+ const pageDefNext = this.pageDef.next ?? [];
+
+ return pageDefNext
+ .map((next: { path: string; redirect?: string }) => {
const { path } = next;
+
+ if (next?.redirect) {
+ return next;
+ }
+
const page = this.model.pages.find((page: PageControllerBase) => {
return path === page.path;
});
@@ -208,12 +238,20 @@ export class PageControllerBase {
let defaultLink;
const nextLink = this.next.find((link) => {
const { condition } = link;
- if (condition) {
- return this.model.conditions[condition]?.fn?.(state);
+ if (!condition) {
+ defaultLink = link;
+ }
+ const conditionPassed = this.model.conditions[condition]?.fn?.(state);
+ if (conditionPassed) {
+ return link;
}
- defaultLink = link;
return false;
});
+
+ if (nextLink?.redirect) {
+ return nextLink;
+ }
+
return nextLink?.page ?? defaultLink?.page;
}
@@ -223,6 +261,9 @@ export class PageControllerBase {
*/
getNext(state: any) {
const nextPage = this.getNextPage(state);
+ if (nextPage?.redirect) {
+ return nextPage.redirect;
+ }
const query = { num: 0 };
let queryString = "";
if (nextPage?.repeatField) {
@@ -267,12 +308,19 @@ export class PageControllerBase {
? values.reduce((acc: any, page: any) => ({ ...acc, ...page }), {})
: {};
- return this.components.getFormDataFromState(
- newState as FormSubmissionState
- );
+ return {
+ ...this.components.getFormDataFromState(
+ newState as FormSubmissionState
+ ),
+ ...this.model.fieldsForContext?.getFormDataFromState(
+ newState as FormSubmissionState
+ ),
+ };
}
-
- return this.components.getFormDataFromState(pageState || {});
+ return {
+ ...this.components.getFormDataFromState(pageState || {}),
+ ...this.model.getContextState(state),
+ };
}
getStateFromValidForm(formData: FormPayload) {
@@ -409,7 +457,13 @@ export class PageControllerBase {
relevantState = merge(relevantState, newValue);
//By passing our current relevantState to getNextPage, we will check if we can navigate to this next page (including doing any condition checks if applicable)
- nextPage = nextPage.getNextPage(relevantState);
+ const possibleNextPage = nextPage.getNextPage(relevantState);
+ if (possibleNextPage?.redirect) {
+ nextPage = null;
+ } else {
+ nextPage = possibleNextPage;
+ }
+
//If a nextPage is returned, we must have taken that route through the form so continue our iteration with the new page
}
@@ -432,6 +486,7 @@ export class PageControllerBase {
const shouldRedirectToStartPage =
!this.model.options.previewMode &&
progress.length === 0 &&
+ !request.pre.hasPrepopulatedSessionFromQueryParameter &&
!isStartPage &&
!isInitialisedSession;
@@ -454,7 +509,6 @@ export class PageControllerBase {
}
});
}
-
const viewModel = this.getViewModel(formData, num);
viewModel.startPage = startPage!.startsWith("http")
? redirectTo(request, h, startPage!)
@@ -521,7 +575,8 @@ export class PageControllerBase {
await cacheService.mergeState(request, { progress });
- viewModel.backLink = progress[progress.length - 2];
+ viewModel.backLink =
+ progress[progress.length - 2] ?? this.backLinkFallback;
return h.view(this.viewName, viewModel);
};
}
@@ -564,7 +619,7 @@ export class PageControllerBase {
path: field.name,
href: `#${field.name}`,
name: field.name,
- text: "The selected file must be smaller than 5MB",
+ text: `The selected file must be smaller than ${config.maxFileSizeStringInMb}MB`,
};
});
@@ -740,7 +795,11 @@ export class PageControllerBase {
* TODO:- proceed is interfering with subclasses
*/
proceed(request: HapiRequest, h: HapiResponseToolkit, state) {
- return proceed(request, h, this.getNext(state));
+ const nextPage = this.getNext(state);
+ if (nextPage?.redirect) {
+ return proceed(request, h, nextPage?.redirect);
+ }
+ return proceed(request, h, nextPage);
}
getPartialMergeState(value) {
@@ -822,7 +881,7 @@ export class PageControllerBase {
private renderWithErrors(request, h, payload, num, progress, errors) {
const viewModel = this.getViewModel(payload, num, errors);
- viewModel.backLink = progress[progress.length - 2];
+ viewModel.backLink = progress[progress.length - 2] ?? this.backLinkFallback;
this.setPhaseTag(viewModel);
this.setFeedbackDetails(viewModel, request);
diff --git a/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts b/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts
new file mode 100644
index 0000000000..a85ee9e0f0
--- /dev/null
+++ b/runner/src/server/plugins/engine/pageControllers/PlaybackUploadPageController.ts
@@ -0,0 +1,109 @@
+import { PageController } from "server/plugins/engine/pageControllers/PageController";
+import { FormModel } from "server/plugins/engine/models";
+import { Page } from "@xgovformbuilder/model";
+import { FormComponent } from "server/plugins/engine/components";
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import joi from "joi";
+import { FormSubmissionErrors } from "../types";
+export class PlaybackUploadPageController extends PageController {
+ inputComponent: FormComponent;
+ retryUploadViewModel = {
+ name: "retryUpload",
+ type: "RadiosField",
+ options: {},
+ schema: {},
+ fieldset: {
+ legend: {
+ text: "Would you like to upload a new image?",
+ isPageHeading: false,
+ classes: "govuk-fieldset__legend--s",
+ },
+ },
+ items: [
+ {
+ value: true,
+ text: "Yes - I would like to upload a new image",
+ },
+ {
+ value: false,
+ text: "No - I'm happy with the image",
+ },
+ ],
+ };
+
+ constructor(model: FormModel, pageDef: Page, inputComponent: FormComponent) {
+ super(model, pageDef);
+ this.inputComponent = inputComponent;
+ this.formSchema = joi.object({
+ crumb: joi.string(),
+ retryUpload: joi
+ .string()
+ .required()
+ .allow("true", "false")
+ .label("if you would like to upload a new image"),
+ });
+ }
+
+ /**
+ * Gets the radio button view model for the "Would you like to upload a new image?" question
+ * @param error - if the user hasn't chosen an option and tries to continue, add the required field error to the field
+ * @returns the view model for the radio button component
+ * */
+ getRetryUploadViewModel(errors?: FormSubmissionErrors) {
+ let viewModel = { ...this.retryUploadViewModel };
+ errors?.errorList?.forEach((err) => {
+ if (err.name === viewModel.name) {
+ viewModel.errorMessage = {
+ text: err.text,
+ };
+ }
+ });
+ return viewModel;
+ }
+
+ makeGetRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { cacheService } = request.services([]);
+
+ const state = await cacheService.getState(request);
+ const { progress = [] } = state;
+ let sectionTitle = this.section?.title;
+ return h.view("upload-playback", {
+ sectionTitle: sectionTitle,
+ showTitle: true,
+ pageTitle: "Check your image",
+ backLink: progress[progress.length - 1] ?? this.backLinkFallback,
+ radios: this.getRetryUploadViewModel(),
+ });
+ };
+ }
+
+ makePostRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { cacheService } = request.services([]);
+
+ const state = await cacheService.getState(request);
+ const { progress = [] } = state;
+ const { payload } = request;
+ const result = this.formSchema.validate(payload, this.validationOptions);
+ if (result.error) {
+ const errors = this.getErrors(result);
+ let sectionTitle = this.section?.title;
+ return h.view("upload-playback", {
+ sectionTitle: sectionTitle,
+ showTitle: true,
+ pageTitle: "Check your image",
+ uploadErrors: errors,
+ backLink: progress[progress.length - 2] ?? this.backLinkFallback,
+ radios: this.getRetryUploadViewModel(errors),
+ });
+ }
+
+ if (payload.retryUpload === "true") {
+ return h.redirect(`/${this.model.basePath}${this.path}`);
+ }
+
+ return h.redirect(this.getNext(request.payload));
+ };
+ }
+}
diff --git a/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts
index d56472dd58..2f36554180 100644
--- a/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts
+++ b/runner/src/server/plugins/engine/pageControllers/SummaryPageController.ts
@@ -8,6 +8,8 @@ import {
RelativeUrl,
} from "../feedback";
import config from "server/config";
+import { FeesModel } from "server/plugins/engine/models/submission";
+import { isMultipleApiKey } from "@xgovformbuilder/model";
export class SummaryPageController extends PageController {
// Extract the error handling into a new method
@@ -155,7 +157,9 @@ export class SummaryPageController extends PageController {
* If the user does not agree to the declaration, the page will be rerendered with a warning.
*/
if (summaryViewModel.declaration && !summaryViewModel.skipSummary) {
- const { declaration } = request.payload as { declaration?: any };
+ const { declaration } = request.payload as {
+ declaration?: any;
+ };
if (!declaration) {
request.yar.flash(
@@ -182,50 +186,67 @@ export class SummaryPageController extends PageController {
webhookData: summaryViewModel.validatedWebhookData,
});
+ const feesModel = FeesModel(model, state);
+
/**
* If a user does not need to pay, redirect them to /status
*/
- if (
- !summaryViewModel.fees ||
- (summaryViewModel.fees.details ?? []).length === 0
- ) {
+ if ((feesModel?.details ?? [])?.length === 0) {
return redirectTo(request, h, `/${request.params.id}/status`);
}
- // user must pay for service
- const description = payService.descriptionFromFees(summaryViewModel.fees);
+ const payReturnUrl =
+ this.model.feeOptions?.payReturnUrl ?? config.payReturnUrl;
+
+ request.logger.info(
+ `payReturnUrl has been configured to ${payReturnUrl}`
+ );
+
const url = new URL(
- `${config.payReturnUrl}/${request.params.id}/status`
+ `${payReturnUrl}/${request.params.id}/status`
).toString();
- const res = await payService.payRequest(
- summaryViewModel.fees,
- summaryViewModel.payApiKey || "",
- url
- );
- request.yar.set("basePath", model.basePath);
- await cacheService.mergeState(request, {
+ const payStateMeta = payService.createPayStateMeta({
+ feesModel: feesModel!,
+ payApiKey: this.payApiKey,
+ url,
+ });
+
+ const res = await payService.payRequestFromMeta(payStateMeta);
+
+ // TODO:- refactor - this is repeated in applicationStatus
+ const payState = {
pay: {
payId: res.payment_id,
reference: res.reference,
self: res._links.self.href,
- returnUrl: new URL(
- `${config.payReturnUrl}/${request.params.id}/status`
- ).toString(),
- meta: {
- amount: summaryViewModel.fees.total,
- description,
- attempts: 1,
- payApiKey: summaryViewModel.payApiKey,
- },
+ next_url: res._links.next_url.href,
+ returnUrl: url,
+ meta: payStateMeta,
},
- });
+ };
+
+ request.yar.set("basePath", model.basePath);
+ await cacheService.mergeState(request, payState);
summaryViewModel.webhookDataPaymentReference = res.reference;
await cacheService.mergeState(request, {
webhookData: summaryViewModel.validatedWebhookData,
});
- return redirectTo(request, h, res._links.next_url.href);
+ const payRedirectUrl = payState.pay.next_url;
+ const { showPaymentSkippedWarningPage } = this.model.feeOptions;
+
+ const { skipPayment } = request.payload;
+ if (skipPayment === "true" && showPaymentSkippedWarningPage) {
+ payState.pay.meta.attempts = 0;
+ await cacheService.mergeState(request, payState);
+ return h
+ .redirect(`/${request.params.id}/status/payment-skip-warning`)
+ .takeover();
+ }
+
+ await cacheService.mergeState(request, payState);
+ return h.redirect(payRedirectUrl);
};
}
@@ -277,4 +298,14 @@ export class SummaryPageController extends PageController {
},
};
}
+
+ get payApiKey(): string {
+ const modelDef = this.model.def;
+ const payApiKey = modelDef.feeOptions?.payApiKey ?? def.payApiKey;
+
+ if (isMultipleApiKey(payApiKey)) {
+ return payApiKey[config.apiEnv] ?? payApiKey.test ?? payApiKey.production;
+ }
+ return payApiKey;
+ }
}
diff --git a/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts b/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts
new file mode 100644
index 0000000000..09b0c8b217
--- /dev/null
+++ b/runner/src/server/plugins/engine/pageControllers/UploadPageController.ts
@@ -0,0 +1,60 @@
+import { PageController } from "server/plugins/engine/pageControllers/PageController";
+import { FormModel } from "server/plugins/engine/models";
+import { HapiRequest, HapiResponseToolkit } from "server/types";
+import { PlaybackUploadPageController } from "server/plugins/engine/pageControllers/PlaybackUploadPageController";
+import { FormComponent } from "server/plugins/engine/components";
+
+function isUploadField(component: FormComponent) {
+ return component.type === "FileUploadField";
+}
+
+export class UploadPageController extends PageController {
+ playback: PlaybackUploadPageController;
+ inputComponent: FormComponent;
+ constructor(model: FormModel, pageDef: any) {
+ super(model, pageDef);
+ const inputComponent = this.components?.items?.find(isUploadField);
+ if (!inputComponent) {
+ throw Error(
+ "UploadPageController initialisation failed, no file upload component was found"
+ );
+ }
+ this.playback = new PlaybackUploadPageController(
+ model,
+ pageDef,
+ inputComponent as FormComponent
+ );
+ this.inputComponent = inputComponent as FormComponent;
+ }
+
+ makeGetRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { query } = request;
+ const { view } = query;
+
+ if (view === "playback") {
+ return this.playback.makeGetRouteHandler()(request, h);
+ }
+
+ return super.makeGetRouteHandler()(request, h);
+ };
+ }
+
+ makePostRouteHandler() {
+ return async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const { query } = request;
+
+ if (query?.view === "playback") {
+ return this.playback.makePostRouteHandler()(request, h);
+ }
+
+ const defaultRes = super.makePostRouteHandler()(request, h);
+
+ if (request.pre?.warning) {
+ return h.redirect("?view=playback");
+ }
+
+ return defaultRes;
+ };
+ }
+}
diff --git a/runner/src/server/plugins/engine/pageControllers/helpers.ts b/runner/src/server/plugins/engine/pageControllers/helpers.ts
index 74b48293fb..5a698f4f31 100644
--- a/runner/src/server/plugins/engine/pageControllers/helpers.ts
+++ b/runner/src/server/plugins/engine/pageControllers/helpers.ts
@@ -9,6 +9,8 @@ import { SummaryPageController } from "./SummaryPageController";
import { PageControllerBase } from "./PageControllerBase";
import { RepeatingFieldPageController } from "./RepeatingFieldPageController";
import { Page } from "@xgovformbuilder/model";
+import { UploadPageController } from "server/plugins/engine/pageControllers/UploadPageController";
+import { MultiStartPageController } from "server/plugins/engine/pageControllers/MultiStartPageController";
const PageControllers = {
DobPageController,
@@ -19,6 +21,8 @@ const PageControllers = {
SummaryPageController,
PageControllerBase,
RepeatingFieldPageController,
+ UploadPageController,
+ MultiStartPageController,
};
export const controllerNameFromPath = (filePath: string) => {
diff --git a/runner/src/server/plugins/engine/pageControllers/validationOptions.ts b/runner/src/server/plugins/engine/pageControllers/validationOptions.ts
index c3a2b975a4..ddf0ee4a8a 100644
--- a/runner/src/server/plugins/engine/pageControllers/validationOptions.ts
+++ b/runner/src/server/plugins/engine/pageControllers/validationOptions.ts
@@ -3,19 +3,24 @@ import { ValidationOptions } from "joi";
* see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax
*/
const messageTemplate = {
- required: "{{#label}} is required",
- max: "{{#label}} must be {{#limit}} characters or fewer",
- min: "{{#label}} must be at least {{#limit}} characters",
+ required: "Enter {{#label}}",
+ selectRequired: "Select {{#label}}",
+ max: "{{#label}} must be {{#limit}} characters or less",
+ min: "{{#label}} must be {{#limit}} characters or more",
regex: "enter a valid {{#label}}",
email: "{{#label}} must be a valid email address",
date: "{{#label}} must be a valid date",
dateMin: "{{#label}} must be on or after {{#limit}}",
dateMax: "{{#label}} must be on or before {{#limit}}",
number: "{{#label}} must be a number",
- numberMin: "{{#label}} must be {{#limit}} or more",
- numberMax: "{{#label}} must be {{#limit}} or less",
+ numberMin: "{{#label}} must be {{#limit}} or higher",
+ numberMax: "{{#label}} must be {{#limit}} or lower",
format: "Enter a valid {{#label}}",
maxWords: "{{#label}} must be {{#limit}} words or fewer",
+ dateRequired: "{{#label}} must be a real date",
+ dateFormat: "{{#label}} must be a real date",
+ dateMin: "{{#label}} must be the same as or after {{#limit}}",
+ dateMax: "{{#label}} must be the same as or before {{#limit}}",
};
export const messages: ValidationOptions["messages"] = {
@@ -38,8 +43,13 @@ export const messages: ValidationOptions["messages"] = {
"number.min": messageTemplate.numberMin,
"number.max": messageTemplate.numberMax,
- "any.required": messageTemplate.required,
+ "any.required": messageTemplate.selectRequired,
"any.empty": messageTemplate.required,
+
+ "date.base": messageTemplate.dateRequired,
+ "date.format": messageTemplate.dateFormat,
+ "date.min": messageTemplate.dateMin,
+ "date.max": messageTemplate.dateMax,
};
export const validationOptions: ValidationOptions = {
diff --git a/runner/src/server/plugins/engine/plugin.ts b/runner/src/server/plugins/engine/plugin.ts
index da5efba576..62e0fd653d 100644
--- a/runner/src/server/plugins/engine/plugin.ts
+++ b/runner/src/server/plugins/engine/plugin.ts
@@ -1,6 +1,6 @@
import path from "path";
import { configure } from "nunjucks";
-import { redirectTo } from "./helpers";
+import { getValidStateFromQueryParameters, redirectTo } from "./helpers";
import { FormConfiguration } from "@xgovformbuilder/model";
import { HapiRequest, HapiResponseToolkit, HapiServer } from "server/types";
@@ -9,7 +9,7 @@ import Boom from "boom";
import { PluginSpecificConfiguration } from "@hapi/hapi";
import { FormPayload } from "./types";
import { shouldLogin } from "server/plugins/auth";
-import config from "config";
+import config from "../../config";
configure([
// Configure Nunjucks to allow rendering of content that is revealed conditionally.
@@ -176,13 +176,57 @@ export const plugin = {
if (model) {
return getStartPageRedirect(request, h, id, model);
}
+
+ if (config.serviceStartPage) {
+ return h.redirect(config.serviceStartPage);
+ }
+
throw Boom.notFound("No default form found");
},
});
+ const queryParamPreHandler = async (
+ request: HapiRequest,
+ h: HapiResponseToolkit
+ ) => {
+ const { query } = request;
+ const { id } = request.params;
+ const model = forms[id];
+ if (!model) {
+ throw Boom.notFound("No form found for id");
+ }
+
+ const prePopFields = model.fieldsForPrePopulation;
+ if (
+ Object.keys(query).length === 0 ||
+ Object.keys(prePopFields).length === 0
+ ) {
+ return h.continue;
+ }
+ const { cacheService } = request.services([]);
+ const state = await cacheService.getState(request);
+ const newValues = getValidStateFromQueryParameters(
+ prePopFields,
+ query,
+ state
+ );
+ await cacheService.mergeState(request, newValues);
+ if (Object.keys(newValues).length > 0) {
+ h.request.pre.hasPrepopulatedSessionFromQueryParameter = true;
+ }
+ return h.continue;
+ };
+
server.route({
method: "get",
path: "/{id}",
+ options: {
+ pre: [
+ {
+ method: queryParamPreHandler,
+ },
+ ],
+ },
handler: (request: HapiRequest, h: HapiResponseToolkit) => {
const { id } = request.params;
const model = forms[id];
@@ -196,6 +240,13 @@ export const plugin = {
server.route({
method: "get",
path: "/{id}/{path*}",
+ options: {
+ pre: [
+ {
+ method: queryParamPreHandler,
+ },
+ ],
+ },
handler: (request: HapiRequest, h: HapiResponseToolkit) => {
const { path, id } = request.params;
const model = forms[id];
@@ -228,7 +279,12 @@ export const plugin = {
const { uploadService } = server.services([]);
const handleFiles = (request: HapiRequest, h: HapiResponseToolkit) => {
- return uploadService.handleUploadRequest(request, h);
+ const { path, id } = request.params;
+ const model = forms[id];
+ const page = model?.pages.find(
+ (page) => normalisePath(page.path) === normalisePath(path)
+ );
+ return uploadService.handleUploadRequest(request, h, page.pageDef);
};
const postHandler = async (
@@ -265,8 +321,8 @@ export const plugin = {
parse: true,
multipart: { output: "stream" },
maxBytes: uploadService.fileSizeLimit,
- failAction: async (request: any, h: HapiResponseToolkit) => {
- request.server?.plugins?.crumb?.generate?.(request, h);
+ failAction: async (request: HapiRequest, h: HapiResponseToolkit) => {
+ request.server.plugins.crumb.generate?.(request, h);
return h.continue;
},
},
diff --git a/runner/src/server/plugins/engine/views/components/html.html b/runner/src/server/plugins/engine/views/components/html.html
index 349b5b459b..0456cae4f0 100644
--- a/runner/src/server/plugins/engine/views/components/html.html
+++ b/runner/src/server/plugins/engine/views/components/html.html
@@ -1,3 +1,3 @@
{% macro Html(component) %}
- {{ component.model.content | safe }}
+ {{ component.model.content | safe }}
{% endmacro %}
diff --git a/runner/src/server/plugins/engine/views/index.html b/runner/src/server/plugins/engine/views/index.html
index 9dd879f798..2226e1e913 100644
--- a/runner/src/server/plugins/engine/views/index.html
+++ b/runner/src/server/plugins/engine/views/index.html
@@ -1,7 +1,12 @@
+{% extends 'layout.html' %}
+
+{% block templateImports %}
+ {{ super() }}
+{% endblock %}}
+
{% from "error-summary/macro.njk" import govukErrorSummary %}
-{% from "partials/components.html" import componentList %}
+{% from "partials/components.html" import componentList with context %}
-{% extends 'layout.html' %}
{% block content %}
{% set gridSize = "full" if components[0].type == 'FlashCard' else "two-thirds" %}
diff --git a/runner/src/server/plugins/engine/views/partials/components.html b/runner/src/server/plugins/engine/views/partials/components.html
index 32ae723acd..8288367244 100644
--- a/runner/src/server/plugins/engine/views/partials/components.html
+++ b/runner/src/server/plugins/engine/views/partials/components.html
@@ -1,6 +1,6 @@
{% macro componentList(components) %}
{% for component in components %}
- {% import "../components/" + component.type.toLowerCase() + ".html" as view %}
+ {% import "../components/" + component.type.toLowerCase() + ".html" as view with context %}
{{ view[component.type](component) }}
{% endfor %}
{% endmacro %}
diff --git a/runner/src/server/plugins/initialiseSession/helpers.ts b/runner/src/server/plugins/initialiseSession/helpers.ts
index ac46b3cfed..a41a15e057 100644
--- a/runner/src/server/plugins/initialiseSession/helpers.ts
+++ b/runner/src/server/plugins/initialiseSession/helpers.ts
@@ -47,6 +47,7 @@ export function generateSessionTokenForForm(callback, formId) {
},
{
key: config.initialisedSessionKey,
+ algorithm: config.initialisedSessionAlgorithm,
},
{
ttlSec: config.initialisedSessionTimeout / 1000,
@@ -54,6 +55,21 @@ export function generateSessionTokenForForm(callback, formId) {
);
}
+export function verifyToken(decodedToken) {
+ try {
+ Jwt.token.verify(decodedToken, {
+ key: config.initialisedSessionKey,
+ algorithm: config.initialisedSessionAlgorithm,
+ });
+ return { isValid: true };
+ } catch (err) {
+ return {
+ isValid: false,
+ error: `${err}`,
+ };
+ }
+}
+
export const callbackValidation = (safelist = config.safelist) =>
joi.string().custom((value, helpers) => {
const hostname = new URL(value).hostname;
diff --git a/runner/src/server/plugins/initialiseSession/initialiseSession.ts b/runner/src/server/plugins/initialiseSession/initialiseSession.ts
index 8acb01fda5..eaed1e7598 100644
--- a/runner/src/server/plugins/initialiseSession/initialiseSession.ts
+++ b/runner/src/server/plugins/initialiseSession/initialiseSession.ts
@@ -2,6 +2,7 @@ import { Plugin, Request } from "@hapi/hapi";
import {
callbackValidation,
generateSessionTokenForForm,
+ verifyToken,
webhookToSessionData,
} from "./helpers";
import { InitialiseSessionOptions, InitialiseSession } from "./types";
@@ -9,6 +10,7 @@ import path from "path";
import { WebhookSchema } from "server/schemas/types";
import Jwt from "@hapi/jwt";
import { SpecialPages } from "@xgovformbuilder/model";
+import Boom from "boom";
type ConfirmationPage = SpecialPages["confirmationPage"];
@@ -31,8 +33,15 @@ export const initialiseSession: Plugin = {
handler: async function (request, h) {
const { cacheService } = request.services([]);
const { token } = request.params;
- const { decoded } = Jwt.token.decode(token);
- const { payload } = decoded;
+ const tokenArtifacts = Jwt.token.decode(token);
+ const { isValid, error } = verifyToken(tokenArtifacts);
+
+ if (!isValid) {
+ request.logger.error([`GET /session/${token}`, "invalid JWT"], error);
+ throw Boom.badRequest();
+ }
+
+ const { payload } = tokenArtifacts.decoded;
const { redirectPath } = await cacheService.activateSession(
token,
request
diff --git a/runner/src/server/plugins/initialiseSession/types.ts b/runner/src/server/plugins/initialiseSession/types.ts
index 6002e7cec8..531cf57570 100644
--- a/runner/src/server/plugins/initialiseSession/types.ts
+++ b/runner/src/server/plugins/initialiseSession/types.ts
@@ -1,3 +1,5 @@
+import { ContentComponentsDef } from "@xgovformbuilder/model";
+
export type InitialiseSession = {
safelist: string[];
};
@@ -8,6 +10,15 @@ export type InitialiseSessionOptions = {
message?: string;
htmlMessage?: string;
title?: string;
+ skipSummary?: {
+ redirectUrl: string;
+ };
+ customText: {
+ title: string;
+ paymentSkipped?: false | string;
+ nextSteps?: false | string;
+ };
+ components: ContentComponentsDef[];
};
export type DecodedSessionToken = {
diff --git a/runner/src/server/plugins/logging.ts b/runner/src/server/plugins/logging.ts
index f892f5bb61..4adfcb462f 100644
--- a/runner/src/server/plugins/logging.ts
+++ b/runner/src/server/plugins/logging.ts
@@ -11,6 +11,8 @@ export default {
return { level: label };
},
},
+ debug: config.isDev,
+ logRequestStart: config.isDev,
logRequestComplete: config.isDev,
ignoreFunc: (_options, request) =>
request.path.startsWith("/assets") || request.url.contains("assets"),
diff --git a/runner/src/server/plugins/queue.ts b/runner/src/server/plugins/queue.ts
new file mode 100644
index 0000000000..1ed63d621b
--- /dev/null
+++ b/runner/src/server/plugins/queue.ts
@@ -0,0 +1,41 @@
+import config from "server/config";
+import { spawnSync } from "child_process";
+const DEFAULT_OPTIONS = {
+ enableQueueService: config.enableQueueService,
+};
+export const pluginQueue = {
+ name: "queue",
+ register: async function (server, options) {
+ if (DEFAULT_OPTIONS.enableQueueService) {
+ const schemaLocation = require.resolve(
+ "@xgovformbuilder/queue-model/schema.prisma"
+ );
+
+ const child = spawnSync(
+ "prisma",
+ ["migrate", "deploy", "--schema", schemaLocation],
+ {
+ encoding: "utf-8",
+ stdio: "inherit",
+ }
+ );
+
+ if (child.error) {
+ server.log(["error", "queue initialisation"], child.error);
+ process.exit(1);
+ }
+ if (child.stdout) {
+ server.log(["queue initialisation", "child process"], child.stdout);
+ server.log(
+ ["queue initialisation"],
+ "Database migration was successful, continuing"
+ );
+ }
+ } else {
+ server.log(
+ ["queue initialisation"],
+ "Queue service not enabled, skipping initialisation"
+ );
+ }
+ },
+};
diff --git a/runner/src/server/plugins/router.ts b/runner/src/server/plugins/router.ts
index 90ad7b627d..b02941d253 100644
--- a/runner/src/server/plugins/router.ts
+++ b/runner/src/server/plugins/router.ts
@@ -40,13 +40,29 @@ export default {
{
method: "get",
path: "/help/cookies",
- handler: async (_request: HapiRequest, h: HapiResponseToolkit) => {
- return h.view("help/cookies");
+ handler: async (request: HapiRequest, h: HapiResponseToolkit) => {
+ const cookiesPolicy = request.state.cookies_policy;
+ let analytics =
+ cookiesPolicy?.analytics === "on" ? "accept" : "reject";
+ return h.view("help/cookies", {
+ analytics,
+ });
},
},
{
method: "post",
options: {
+ payload: {
+ parse: true,
+ multipart: true,
+ failAction: async (
+ request: HapiRequest,
+ h: HapiResponseToolkit
+ ) => {
+ request.server.plugins.crumb.generate?.(request, h);
+ return h.continue;
+ },
+ },
validate: {
payload: Joi.object({
cookies: Joi.string()
@@ -78,7 +94,7 @@ export default {
usage: accept,
},
{
- isHttpOnly: true,
+ isHttpOnly: false,
path: "/",
}
);
@@ -148,20 +164,24 @@ export default {
let name = "";
const { referer } = request.headers;
- console.log("🚀 ~ referer", referer)
if (referer) {
- const match = referer.match(/https?:\/\/[^/]+\/([^/]+)(\/([^/]+))?.*/);
+ const match = referer.match(
+ /https?:\/\/[^/]+\/([^/]+)(\/([^/]+))?.*/
+ );
if (match && match.length > 1) {
startPage = `/${match[1]}`;
- name = (match[1].split(/(?=[A-Z])/)).toString()
- name = (name[0]+name.slice(1).toLowerCase()).replaceAll(",", " ")
+ name = match[1].split(/(?=[A-Z])/).toString();
+ name = (name[0] + name.slice(1).toLowerCase()).replaceAll(
+ ",",
+ " "
+ );
}
}
-
+
return h.view("timeout", {
startPage,
- name
+ name,
});
},
});
diff --git a/runner/src/server/plugins/views.ts b/runner/src/server/plugins/views.ts
index 92544d2212..1a1544bcdd 100644
--- a/runner/src/server/plugins/views.ts
+++ b/runner/src/server/plugins/views.ts
@@ -7,6 +7,7 @@ import { capitalize } from "lodash";
import pkg from "../../../package.json";
import config from "../config";
import { HapiRequest } from "../types";
+import additionalContexts from "../templates/additionalContexts.json";
const basedir = path.join(process.cwd(), "..");
@@ -41,6 +42,7 @@ export default {
autoescape: true,
watch: false,
});
+ environment.addGlobal("additionalContexts", additionalContexts);
environment.addFilter("isArray", (x) => Array.isArray(x));
options.compileOptions.environment = environment;
diff --git a/runner/src/server/prismaClient.ts b/runner/src/server/prismaClient.ts
new file mode 100644
index 0000000000..b52d782457
--- /dev/null
+++ b/runner/src/server/prismaClient.ts
@@ -0,0 +1,72 @@
+import type { Prisma } from "@xgovformbuilder/queue-model";
+import { PrismaClient } from "@xgovformbuilder/queue-model";
+import config from "./config";
+import logger from "pino";
+
+const prismaLogger = logger();
+
+const logLevel: Prisma.LogDefinition[] = [
+ {
+ emit: "event",
+ level: "error",
+ },
+ {
+ emit: "event",
+ level: "warn",
+ },
+];
+
+if (config.isDev) {
+ logLevel.push(
+ {
+ emit: "event",
+ level: "query",
+ },
+ {
+ emit: "event",
+ level: "info",
+ }
+ );
+}
+
+export const prisma: PrismaClient = new PrismaClient({
+ log: logLevel,
+});
+
+if (config.enableQueueService && config.queueType === "MYSQL") {
+ prismaLogger.info(
+ "ENABLE_QUEUE_SERVICE is true, and queueType is set to MYSQL connecting to Prisma"
+ );
+ prisma.$connect().catch((error) => {
+ prismaLogger.fatal(
+ `ENABLE_QUEUE_SERVICE is set to true, and queueType is set to MYSQL but Prisma failed to connect ${error}, exiting with status 1`
+ );
+ process.exit(1);
+ });
+
+ process.on("query", (e: Prisma.QueryEvent) => {
+ if (!config.isTest) {
+ prismaLogger.info(`
+ Prisma Query: ${e.query} \r\n
+ Duration: ${e.duration}ms \r\n
+ Params: ${e.params}
+ `);
+ }
+ });
+
+ process.on("warn", (e) => {
+ prismaLogger.warn(e);
+ });
+
+ process.on("info", (e) => {
+ prismaLogger.info(e);
+ });
+
+ process.on("error", (e) => {
+ prismaLogger.error(e);
+ });
+
+ process.on("beforeExit", () => {
+ prismaLogger.info("Prisma is exiting");
+ });
+}
diff --git a/runner/src/server/schemas/types.ts b/runner/src/server/schemas/types.ts
index d2781ba662..a64287248c 100644
--- a/runner/src/server/schemas/types.ts
+++ b/runner/src/server/schemas/types.ts
@@ -1,9 +1,10 @@
-import { ComponentType, ConfirmationPage } from "@xgovformbuilder/model";
+import { ConfirmationPage } from "@xgovformbuilder/model";
import { FeeDetails } from "server/services/payService";
+import { DataType } from "server/plugins/engine/components/types";
export type Field = {
key: string;
- type: ComponentType;
+ type: DataType | undefined;
title: string;
answer: any;
};
diff --git a/runner/src/server/services/QueueService.ts b/runner/src/server/services/QueueService.ts
new file mode 100644
index 0000000000..ec59d81a1e
--- /dev/null
+++ b/runner/src/server/services/QueueService.ts
@@ -0,0 +1,26 @@
+import { HapiServer } from "server/types";
+
+type QueueResponse = [number | string, string | undefined];
+
+export abstract class QueueService {
+ logger: HapiServer["logger"];
+
+ constructor(server: HapiServer) {
+ this.logger = server.logger;
+ }
+
+ /**
+ * Send data from form submission to submission queue
+ * @param data
+ * @param url
+ * @param allowRetry
+ * @returns The ID of the newly added row, or undefined in the event of an error
+ */
+ abstract sendToQueue(
+ data: object,
+ url: string,
+ allowRetry?: boolean
+ ): Promise;
+
+ abstract getReturnRef(rowId: number | string): Promise;
+}
diff --git a/runner/src/server/services/cacheService.ts b/runner/src/server/services/cacheService.ts
index ea5ffa3188..c8fee6ce3c 100644
--- a/runner/src/server/services/cacheService.ts
+++ b/runner/src/server/services/cacheService.ts
@@ -150,10 +150,10 @@ export const catboxProvider = () => {
* More information at {@link https://hapi.dev/module/catbox/api}
*/
const provider = {
- constructor: (redisHost) ? CatboxRedis : CatboxMemory,
+ constructor: redisHost ? CatboxRedis.Engine : CatboxMemory.Engine,
options: {},
};
-
+
if (redisHost) {
const redisOptions: {
password?: string;
diff --git a/runner/src/server/services/emailService.ts b/runner/src/server/services/emailService.ts
index d1b8073709..bc81292ac2 100644
--- a/runner/src/server/services/emailService.ts
+++ b/runner/src/server/services/emailService.ts
@@ -1,10 +1,14 @@
-import AWS from "aws-sdk";
-import MailComposer from "nodemailer/lib/mail-composer";
+// import AWS from "aws-sdk";
+// import MailComposer from "nodemailer/lib/mail-composer";
import config from "../config";
import { HapiServer } from "../types";
-import { UploadService } from "./uploadService";
+import { UploadService } from "./upload";
+/**
+ * @deprecated This service is not in use currently. If you would like to send emails to users, use
+ * the email or Notify output which both use the NotifyService
+ */
export class EmailService {
/**
* This service is responsible for sending emails. It is currently only designed to work with AWS SES, which is not available in EU West 2. You must also have a verified domain for SES.
@@ -49,12 +53,12 @@ export class EmailService {
);
}
- const mailComposer = new MailComposer(mailOptions);
- const message = await mailComposer.compile().build();
+ // const mailComposer = new MailComposer(mailOptions);
+ // const message = await mailComposer.compile().build();
// SES is not available in eu-west-2
- return new AWS.SES({ apiVersion: "2010-12-01", region: "eu-west-1" })
- .sendRawEmail({ RawMessage: { Data: message } })
- .promise();
+ // return new AWS.SES({ apiVersion: "2010-12-01", region: "eu-west-1" })
+ // .sendRawEmail({ RawMessage: { Data: message } })
+ // .promise();
}
}
diff --git a/runner/src/server/services/httpService.ts b/runner/src/server/services/httpService.ts
index e6c646677f..2820ab73f6 100644
--- a/runner/src/server/services/httpService.ts
+++ b/runner/src/server/services/httpService.ts
@@ -25,7 +25,7 @@ export const request: Request = async (method, url, options = {}) => {
}
};
-export const get = (url: string, options?: object) => {
+export const get = (url: string, options?: object) => {
return request("get", url, options);
};
diff --git a/runner/src/server/services/index.ts b/runner/src/server/services/index.ts
index 393fb0e2d3..6e86f289d9 100644
--- a/runner/src/server/services/index.ts
+++ b/runner/src/server/services/index.ts
@@ -1,4 +1,4 @@
-export { UploadService } from "./uploadService";
+export { UploadService, MockUploadService } from "./upload";
export { PayService } from "./payService";
export { NotifyService } from "./notifyService";
export { EmailService } from "./emailService";
diff --git a/runner/src/server/services/mySqlQueueService.ts b/runner/src/server/services/mySqlQueueService.ts
new file mode 100644
index 0000000000..a7943ec8ce
--- /dev/null
+++ b/runner/src/server/services/mySqlQueueService.ts
@@ -0,0 +1,98 @@
+import { HapiServer } from "server/types";
+import { PrismaClient } from "@xgovformbuilder/queue-model";
+import { prisma } from "../prismaClient";
+import config from "../config";
+import { QueueService } from "server/services/QueueService";
+
+type QueueResponse = [number, string | undefined];
+export class MySqlQueueService extends QueueService {
+ prisma: PrismaClient;
+ logger: HapiServer["logger"];
+ interval: number;
+
+ constructor(server: HapiServer) {
+ super(server);
+ this.prisma = prisma;
+ this.interval = parseInt(config.queueServicePollingInterval);
+ }
+
+ /**
+ * Send data from form submission to submission queue
+ * @param data
+ * @param url
+ * @returns The ID of the newly added row, or undefined in the event of an error
+ */
+ async sendToQueue(
+ data: object,
+ url: string,
+ allowRetry = true
+ ): Promise {
+ const rowData = {
+ data: JSON.stringify(data),
+ created_at: new Date(),
+ updated_at: new Date(),
+ webhook_url: url,
+ complete: false,
+ retry_counter: 0,
+ allow_retry: allowRetry,
+ };
+ const row = await this.prisma.submission.create({
+ data: rowData,
+ });
+ this.logger.info(["queueService", "sendToQueue", "success"], row);
+ try {
+ const newRowRef = (await this.pollForRef(row.id)) ?? "UNKNOWN";
+ this.logger.info(
+ ["queueService", "sendToQueue", `Row ref: ${row.id}`],
+ `Return ref: ${newRowRef}`
+ );
+ return [row.id, newRowRef];
+ } catch (err) {
+ this.logger.error(
+ ["QueueService", "sendToQueue", `Row ref: ${row.id}`],
+ "Polling for return reference failed."
+ );
+ return [row.id, undefined];
+ }
+ }
+
+ async pollForRef(rowId: number): Promise {
+ let timeElapsed = 0;
+ return new Promise((resolve, reject) => {
+ const pollInterval = setInterval(async () => {
+ try {
+ const newRef = await this.getReturnRef(rowId);
+ if (newRef) {
+ resolve(newRef);
+ clearInterval(pollInterval);
+ }
+ if (timeElapsed >= 2000) {
+ resolve();
+ clearInterval(pollInterval);
+ }
+ timeElapsed += parseInt(config.queueServicePollingInterval);
+ } catch (err) {
+ this.logger.error(
+ ["QueueService", "pollForRef", `Row ref: ${rowId}`],
+ err
+ );
+ reject();
+ }
+ }, config.queueServicePollingInterval);
+ });
+ }
+ async getReturnRef(rowId: number) {
+ const row = await this.prisma.submission.findUnique({
+ select: {
+ return_reference: true,
+ },
+ where: {
+ id: rowId,
+ },
+ });
+ if (!row) {
+ throw new Error("Submission row not found");
+ }
+ return row.return_reference;
+ }
+}
diff --git a/runner/src/server/services/notifyService.ts b/runner/src/server/services/notifyService.ts
index 75749292e1..ae7f10fb71 100644
--- a/runner/src/server/services/notifyService.ts
+++ b/runner/src/server/services/notifyService.ts
@@ -19,7 +19,8 @@ export type SendNotificationArgs = {
emailAddress: string;
personalisation: Personalisation;
reference: string;
- replyToEmailId?: string;
+ emailReplyToId?: string;
+ escapeURLs?: boolean;
};
export class NotifyService {
@@ -31,12 +32,36 @@ export class NotifyService {
this.logger = server.logger;
}
- parsePersonalisations(options: Personalisation): Personalisation {
+ /**
+ * Escapes markdown-formatted links. If a markdown-formatted link is passed
+ * through in personalisation, Notify will render it as a link. This could
+ * leave an opening to phishing attacks.
+ *
+ * @param value the personalisation value to be escaped
+ */
+ escapeURLs(value: string): string {
+ const specialCharactersPattern = new RegExp(/\[([^\[\]]*)]\(([^\(\)]*)\)/g);
+
+ return value.replaceAll(
+ specialCharactersPattern,
+ (_match, linkText, href) => {
+ return `\\[${linkText}\\]\\(${href})`;
+ }
+ );
+ }
+
+ parsePersonalisations(
+ options: Personalisation,
+ escapeURLs: boolean
+ ): Personalisation {
const entriesWithReplacedBools = Object.entries(options).map(
([key, value]) => {
if (typeof value === "boolean") {
return [key, value ? "yes" : "no"];
}
+ if (typeof value === "string" && escapeURLs) {
+ value = this.escapeURLs(value);
+ }
return [key, value];
}
);
@@ -51,17 +76,18 @@ export class NotifyService {
personalisation,
reference,
emailReplyToId,
+ escapeURLs,
} = args;
let { apiKey } = args;
if (isMultipleApiKey(apiKey)) {
- apiKey = (config.apiEnv === "production"
- ? apiKey.production ?? apiKey.test
- : apiKey.test ?? apiKey.production) as string;
+ apiKey = (apiKey[config.apiEnv] ??
+ apiKey.test ??
+ apiKey.production) as string;
}
const parsedOptions: SendEmailOptions = {
- personalisation: this.parsePersonalisations(personalisation),
+ personalisation: this.parsePersonalisations(personalisation, escapeURLs),
reference,
emailReplyToId,
};
diff --git a/runner/src/server/services/payService.nanoid.ts b/runner/src/server/services/payService.nanoid.ts
new file mode 100644
index 0000000000..cb7ec103f5
--- /dev/null
+++ b/runner/src/server/services/payService.nanoid.ts
@@ -0,0 +1,8 @@
+import { customAlphabet } from "nanoid";
+import config from "server/config";
+
+export const payReferenceLength = parseInt(config.payReferenceLength ?? 10);
+export const nanoid = customAlphabet(
+ "1234567890ABCDEFGHIJKLMNPQRSTUVWXYZ-_",
+ payReferenceLength
+);
diff --git a/runner/src/server/services/payService.ts b/runner/src/server/services/payService.ts
index c8d8a8f1af..ec58770487 100644
--- a/runner/src/server/services/payService.ts
+++ b/runner/src/server/services/payService.ts
@@ -1,10 +1,10 @@
import config from "../config";
import { get, postJson } from "./httpService";
-import { nanoid } from "nanoid";
import { Fee } from "@xgovformbuilder/model";
-import { FeesModel } from "server/plugins/engine/models/submission";
import { HapiServer } from "server/types";
import { format } from "date-fns";
+import { FeesModel } from "server/plugins/engine/models/submission";
+import { nanoid } from "./payService.nanoid";
export type FeeDetails = Fee & {
multiplyBy?: number; // the value retrieved from multiplier field above (see summary page retrieveFees method)
@@ -24,6 +24,9 @@ export type FeeState = {
attempts: number;
payApiKey: string;
description: string;
+ reportingColumns?: FeesModel["reportingColumns"];
+ returnUrl: string;
+ reference: string;
};
paymentSkipped: boolean;
state?: {
@@ -75,6 +78,9 @@ export class PayService {
};
}
+ /**
+ * @deprecated in favour of this.createPayStateMeta and this.payRequestFromMeta.
+ */
payRequestData(feesModel: FeesModel, returnUrl: string) {
const { total, prefixes, referenceFormat } = feesModel;
return {
@@ -86,13 +92,19 @@ export class PayService {
}
referenceFromFees(prefixes = [], referenceFormat = "") {
+ if (!referenceFormat) {
+ const reference = nanoid();
+ this.logger.info(
+ ["payService", "referenceFromFees"],
+ `no reference format provided, generated random reference: ${reference}`
+ );
+ return reference;
+ }
+
this.logger.info(
["payService", "referenceFromFees"],
- `requested pay reference format ${referenceFormat}`
+ `requested pay reference format: "${referenceFormat}"`
);
- if (!referenceFormat) {
- return nanoid(10);
- }
let reference = referenceFormat;
reference = reference.replace(REFERENCE_TAG.PREFIX, prefixes.join("-"));
@@ -104,7 +116,7 @@ export class PayService {
reference = reference.replace(dateTag, format(new Date(), dateFormat));
}
- reference = `${reference}-${nanoid(10)}`;
+ reference = `${reference}-${nanoid()}`;
this.logger.info(
["payService", "referenceFromFees"],
`generated pay request format ${reference}`
@@ -113,33 +125,41 @@ export class PayService {
return reference;
}
- async retryPayRequest(feeState: FeeState) {
- const { reference, meta, returnUrl } = feeState;
- const { payApiKey, amount, description } = meta;
- let newReference = `${reference.slice(0, -11)}-${nanoid(10)}`;
+ /**
+ * @deprecated in favour of this.createPayStateMeta and this.payRequestFromMeta.
+ */
+ async payRequest(feesModel: FeesModel, apiKey: string, returnUrl: string) {
+ const data = {
+ ...this.options(apiKey),
+ payload: this.payRequestData(feesModel, returnUrl),
+ };
+
+ const { payload } = await postJson(`${config.payApiUrl}/payments`, data);
+ return payload;
+ }
+ async payRequestFromMeta(meta: FeeState["meta"]) {
+ const {
+ payApiKey,
+ amount,
+ description,
+ returnUrl,
+ reference,
+ reportingColumns,
+ } = meta;
const { payload } = await postJson(`${config.payApiUrl}/payments`, {
...this.options(payApiKey),
payload: {
- reference: newReference,
+ reference,
return_url: returnUrl,
description,
amount,
+ metadata: reportingColumns ?? {},
},
});
return payload;
}
- async payRequest(feesModel: FeesModel, apiKey: string, returnUrl: string) {
- const data = {
- ...this.options(apiKey),
- payload: this.payRequestData(feesModel, returnUrl),
- };
-
- const { payload } = await postJson(`${config.payApiUrl}/payments`, data);
- return payload;
- }
-
async payStatus(url: string, apiKey: string) {
const { payload } = await get(url, {
...this.options(apiKey),
@@ -169,4 +189,26 @@ export class PayService {
})
.join(", ");
}
+
+ /**
+ * Additional data to store to the users' state to easily recreate a payment request.
+ */
+ createPayStateMeta(params: {
+ feesModel: FeesModel;
+ payApiKey: string;
+ url: string;
+ }) {
+ const { feesModel, payApiKey, url } = params;
+ const { reportingColumns, prefixes, referenceFormat } = feesModel;
+
+ return {
+ reference: this.referenceFromFees(prefixes, referenceFormat),
+ amount: feesModel.total,
+ attempts: 1,
+ payApiKey,
+ description: this.descriptionFromFees(feesModel),
+ returnUrl: url,
+ ...(reportingColumns && { reportingColumns }),
+ };
+ }
}
diff --git a/runner/src/server/services/pgBossQueueService.ts b/runner/src/server/services/pgBossQueueService.ts
new file mode 100644
index 0000000000..1b6823a07e
--- /dev/null
+++ b/runner/src/server/services/pgBossQueueService.ts
@@ -0,0 +1,176 @@
+import { QueueService } from "server/services/QueueService";
+type QueueResponse = [number | string, string | undefined];
+import PgBoss, { Job, JobWithMetadata } from "pg-boss";
+import config from "server/config";
+
+type QueueReferenceApiResponse = {
+ reference: string;
+};
+
+type JobOutput = {
+ [key: string]: any;
+} & QueueReferenceApiResponse;
+
+export class PgBossQueueService extends QueueService {
+ queue: PgBoss;
+ queueName: string = "submission";
+ queueReferenceApiUrl: string;
+ pollingInterval: number;
+ pollingTimeout: number;
+
+ constructor(server) {
+ super(server);
+ this.logger.info("Using PGBossQueueService");
+ this.queueReferenceApiUrl = config.queueReferenceApiUrl;
+ this.pollingInterval = parseInt(config.queueServicePollingInterval);
+ this.pollingTimeout = parseInt(config.queueServicePollingTimeout);
+
+ const boss = new PgBoss(config.queueDatabaseUrl);
+ this.queue = boss;
+ boss.on("error", this.logger.error);
+ boss.start().catch((e) => {
+ this.logger.error(
+ `Connecting to ${config.queueDatabaseUrl} failed, exiting`
+ );
+ throw e;
+ });
+ }
+
+ /**
+ * Fetches a reference number from `this.queueReferenceApiUrl/{jobId}`.
+ * If a reference number for `jobId` exists, the response body must be {@link QueueReferenceApiResponse}.
+ * This request will happen once, and timeout in 2s.
+ */
+ async getReturnRef(
+ jobId: string
+ ): Promise<{
+ reference: string;
+ state: JobWithMetadata["state"] | "JOB_NOT_FOUND";
+ }> {
+ let job;
+
+ try {
+ job = await this.queue.getJobById(jobId);
+ } catch (e) {
+ this.logger.error(
+ ["PgBossQueueService", "getReturnRef"],
+ `jobId: ${jobId} JOB_NOT_FOUND`
+ );
+ return {
+ reference: "UNKNOWN",
+ state: "JOB_NOT_FOUND",
+ };
+ }
+
+ this.logger.info(
+ ["PgBossQueueService", "getReturnRef"],
+ `found job ${job.id} with state ${job.state}`
+ );
+
+ let reference = "UNKNOWN";
+
+ if (job.state === "completed") {
+ const jobOutput = job.output as JobOutput;
+ reference = jobOutput?.reference;
+ }
+
+ if (job.state === "failed") {
+ this.logger.info(
+ ["PgBossQueueService", "getReturnRef"],
+ `${jobId} failed to be processed by webhook. Returning UNKNOWN`
+ );
+ }
+
+ if (!reference) {
+ this.logger.info(
+ ["PgBossQueueService", "getReturnRef"],
+ `${jobId} was ${job.state} but the job output did not contain reference. Returning UNKNOWN`
+ );
+ }
+
+ return {
+ reference,
+ state: job.state,
+ };
+ }
+
+ async sendToQueue(
+ data: object,
+ url: string,
+ allowRetry = true
+ ): Promise {
+ const logMetadata = ["QueueService", "sendToQueue"];
+ const options: PgBoss.SendOptions = {
+ retryBackoff: true,
+ };
+ if (!allowRetry) {
+ options.retryLimit = 1;
+ }
+
+ let referenceNumber = "UNKNOWN";
+
+ const jobId = await this.queue.send(
+ this.queueName,
+ {
+ data,
+ webhook_url: url,
+ },
+ options
+ );
+
+ if (!jobId) {
+ throw Error("Job could not be created");
+ }
+
+ this.logger.info(logMetadata, `success job created with id: ${jobId}`);
+ try {
+ const newRowRef = await this.pollForRef(jobId);
+ this.logger.info(
+ logMetadata,
+ `jobId: ${jobId} has reference number ${newRowRef}`
+ );
+ return [jobId, newRowRef ?? referenceNumber];
+ } catch (e) {
+ this.logger.error(
+ ["QueueService", "sendToQueue", `jobId: ${jobId}`],
+ `Polling for return reference failed. ${e}`
+ );
+ // TODO:- investigate if this should return UNKNOWN?
+ return [jobId, undefined];
+ }
+ }
+
+ async pollForRef(jobId: string): Promise {
+ let timeElapsed = 0;
+ return new Promise(async (resolve, reject) => {
+ const initialAttempt = await this.getReturnRef(jobId);
+ if (
+ initialAttempt.state === "completed" ||
+ initialAttempt.state === "failed"
+ ) {
+ resolve(initialAttempt.reference);
+ }
+
+ const pollInterval = setInterval(async () => {
+ try {
+ const { reference, state } = await this.getReturnRef(jobId);
+ if (state === "completed" || state === "failed") {
+ // resolve or "exit" this loop when the job has completed or failed (i.e. do not keep polling)
+ clearInterval(pollInterval);
+ resolve(reference);
+ }
+ if (timeElapsed >= this.pollingTimeout) {
+ this.logger.info(
+ `jobId ${jobId} took ${timeElapsed} to reach completed or failed. Polling timeout has lapsed (${this.pollingTimeout})`
+ );
+ clearInterval(pollInterval);
+ resolve();
+ }
+ timeElapsed += this.pollingInterval;
+ } catch (err) {
+ reject();
+ }
+ }, this.pollingInterval);
+ });
+ }
+}
diff --git a/runner/src/server/services/queueStatusService.ts b/runner/src/server/services/queueStatusService.ts
new file mode 100644
index 0000000000..26e27eee58
--- /dev/null
+++ b/runner/src/server/services/queueStatusService.ts
@@ -0,0 +1,117 @@
+import { StatusService } from "server/services/statusService";
+import { HapiRequest, HapiServer } from "server/types";
+import Boom from "boom";
+import { MySqlQueueService } from "server/services/mySqlQueueService";
+import { PgBossQueueService } from "server/services/pgBossQueueService";
+
+export class QueueStatusService extends StatusService {
+ queueService: MySqlQueueService | PgBossQueueService;
+ constructor(server: HapiServer) {
+ super(server);
+ const { queueService } = server.services([]);
+ this.queueService = queueService;
+ }
+
+ async outputRequests(request: HapiRequest) {
+ const state = await this.cacheService.getState(request);
+ let formData = this.webhookArgsFromState(state);
+
+ const { outputs, callback } = state;
+
+ let newReference: string | undefined;
+ let queueReference: number | undefined;
+
+ if (callback) {
+ this.logger.info(
+ ["QueueStatusService", "outputRequests"],
+ `Callback detected for ${request.yar.id} - PUT to ${callback.callbackUrl}`
+ );
+ try {
+ const queueResults = await this.queueService.sendToQueue(
+ formData,
+ callback.callbackUrl
+ );
+ if (!queueResults) {
+ this.logQueueServiceError();
+ }
+ [queueReference, newReference] = queueResults;
+ this.logger.info(
+ ["QueueStatusService", "outputRequests"],
+ `Queue reference: ${queueReference}`
+ );
+ } catch (e) {
+ throw Boom.badRequest(e);
+ }
+ }
+
+ const firstWebhook = outputs?.find((output) => output.type === "webhook");
+ const otherOutputs = outputs?.filter((output) => output !== firstWebhook);
+ if (firstWebhook) {
+ if (!queueReference) {
+ const data = { ...formData };
+ if (!firstWebhook.outputData.sendAdditionalPayMetadata) {
+ delete data?.metadata?.pay;
+ }
+ const queueResults = await this.queueService?.sendToQueue(
+ data,
+ firstWebhook.outputData.url,
+ firstWebhook.outputData.allowRetry
+ );
+ if (!queueResults) {
+ this.logQueueServiceError();
+ }
+ [queueReference, newReference] = queueResults;
+ this.logger.info(
+ ["QueueStatusService", "outputRequests"],
+ `Queue reference: ${queueReference}`
+ );
+ }
+ await this.cacheService.mergeState(request, {
+ reference: newReference ?? state.fees?.paymentReference,
+ });
+ }
+
+ if (!queueReference) {
+ const queueResults = await this.queueService?.sendToQueue(formData, "");
+ if (!queueResults) {
+ this.logQueueServiceError();
+ }
+ [queueReference, newReference] = queueResults;
+ this.logger.info(
+ ["QueueStatusService", "outputRequests"],
+ `Queue reference: ${queueReference}`
+ );
+ }
+
+ const { notify = [], webhook = [] } = this.outputArgs(
+ otherOutputs,
+ formData,
+ newReference,
+ state.pay
+ );
+
+ const requests = [
+ ...notify.map((args) => this.notifyService.sendNotification(args)),
+ ...webhook.map(({ url, sendAdditionalPayMetadata, formData }) =>
+ this.webhookService.postRequest(
+ url,
+ formData,
+ "POST",
+ sendAdditionalPayMetadata
+ )
+ ),
+ ];
+
+ return {
+ reference: newReference,
+ results: Promise.allSettled(requests),
+ };
+ }
+
+ logQueueServiceError() {
+ this.logger.error(
+ ["QueueStatusService", "outputRequests"],
+ "There was an issue sending the submission to the submission queue"
+ );
+ }
+}
diff --git a/runner/src/server/services/statusService.ts b/runner/src/server/services/statusService.ts
index 24af7f0f69..538d8060aa 100644
--- a/runner/src/server/services/statusService.ts
+++ b/runner/src/server/services/statusService.ts
@@ -12,6 +12,8 @@ import { ComponentCollection } from "server/plugins/engine/components/ComponentC
import { FormSubmissionState } from "server/plugins/engine/types";
import { FormModel } from "server/plugins/engine/models";
import Boom from "boom";
+import config from "server/config";
+import nunjucks from "nunjucks";
type WebhookModel = WebhookOutputConfiguration & {
formData: object;
@@ -61,40 +63,63 @@ export class StatusService {
this.notifyService = notifyService;
this.payService = payService;
}
-
- async shouldRetryPay(request): Promise {
+ async shouldShowPayErrorPage(request: HapiRequest): Promise {
const { pay } = await this.cacheService.getState(request);
if (!pay) {
this.logger.info(
- ["StatusService", "shouldRetryPay"],
+ ["StatusService", "shouldShowPayErrorPage"],
"No pay state detected, skipping"
);
return false;
- } else {
- const { self, meta } = pay;
- const { query } = request;
- const { state } = await this.payService.payStatus(self, meta.payApiKey);
- const userSkippedOrLimitReached =
- query?.continue === "true" || meta?.attempts >= 3;
-
- await this.cacheService.mergeState(request, {
- pay: {
- ...pay,
- paymentSkipped: userSkippedOrLimitReached,
- state,
- },
- });
-
- const shouldRetry =
- state.status === "failed" && !userSkippedOrLimitReached;
+ }
+ const { self, meta } = pay;
+ const { query } = request;
+ const { state } = await this.payService.payStatus(self, meta.payApiKey);
+ pay.state = state;
+ if (state.status === "success") {
this.logger.info(
- ["StatusService", "shouldRetryPay"],
- `user ${request.yar.id} - shouldRetryPay: ${shouldRetry}`
+ ["StatusService", "shouldShowPayErrorPage"],
+ `user ${request.yar.id} - shouldShowPayErrorPage: User has succeeded, setting paymentSkipped to false and continuing`
);
- return shouldRetry;
+ pay.paymentSkipped = false;
+ pay.state = state;
+ await this.cacheService.mergeState(request, { pay });
+
+ return false;
+ }
+
+ const form: FormModel = request.server.app.forms[request.params.id];
+ const { maxAttempts, allowSubmissionWithoutPayment } = form.feeOptions;
+
+ this.logger.info(
+ ["StatusService", "shouldShowPayErrorPage"],
+ `user ${request.yar.id} - shouldShowPayErrorPage: User has failed ${meta.attempts} payments`
+ );
+
+ if (!allowSubmissionWithoutPayment) {
+ return true;
}
+
+ const userSkippedOrLimitReached =
+ query?.continue === "true" || meta?.attempts >= maxAttempts;
+
+ await this.cacheService.mergeState(request, {
+ pay: {
+ ...pay,
+ paymentSkipped: userSkippedOrLimitReached,
+ },
+ });
+
+ const shouldRetry = state.status === "failed" && !userSkippedOrLimitReached;
+
+ this.logger.info(
+ ["StatusService", "shouldShowPayErrorPage"],
+ `user ${request.yar.id} - shouldShowPayErrorPage: ${shouldRetry}`
+ );
+
+ return shouldRetry;
}
async outputRequests(request: HapiRequest) {
@@ -106,10 +131,6 @@ export class StatusService {
let newReference;
if (callback) {
- this.logger.info(
- ["StatusService", "outputRequests"],
- `Callback detected for ${request.yar.id}`
- );
this.logger.info(
["StatusService", "outputRequests"],
`Callback detected for ${request.yar.id} - PUT to ${callback.callbackUrl}`
@@ -121,7 +142,7 @@ export class StatusService {
"PUT"
);
} catch (e) {
- throw Boom.badRequest();
+ throw Boom.badRequest(e);
}
}
@@ -130,7 +151,9 @@ export class StatusService {
if (firstWebhook) {
newReference = await this.webhookService.postRequest(
firstWebhook.outputData.url,
- formData
+ { ...formData },
+ "POST",
+ firstWebhook.outputData.sendAdditionalPayMetadata
);
await this.cacheService.mergeState(request, {
reference: newReference,
@@ -146,8 +169,15 @@ export class StatusService {
const requests = [
...notify.map((args) => this.notifyService.sendNotification(args)),
- ...webhook.map(({ url, formData }) =>
- this.webhookService.postRequest(url, formData)
+ ...webhook.map(({ url, sendAdditionalPayMetadata, formData }) =>
+ this.webhookService.postRequest(
+ url,
+ {
+ ...formData,
+ },
+ "POST",
+ sendAdditionalPayMetadata
+ )
),
];
@@ -164,7 +194,7 @@ export class StatusService {
const { pay = {}, webhookData } = state;
const { paymentSkipped } = pay;
const { metadata, fees, ...rest } = webhookData;
- return {
+ const webhookArgs = {
...rest,
...(!paymentSkipped && { fees }),
metadata: {
@@ -173,6 +203,16 @@ export class StatusService {
paymentSkipped: paymentSkipped ?? false,
},
};
+
+ if (pay) {
+ webhookArgs.metadata.pay = {
+ payId: pay.payId,
+ reference: pay.reference,
+ state: pay.state ?? {},
+ };
+ }
+
+ return webhookArgs;
}
emailOutputsFromState(
@@ -187,6 +227,7 @@ export class StatusService {
personalisation = {},
addReferencesToPersonalisation = false,
emailReplyToId,
+ escapeURLs,
} = outputData;
return {
@@ -204,6 +245,7 @@ export class StatusService {
templateId,
emailAddress,
emailReplyToId,
+ escapeURLs,
};
}
@@ -230,11 +272,11 @@ export class StatusService {
notify.push(args);
}
if (isWebhookModel(currentValue.outputData)) {
- const { url } = currentValue.outputData;
- webhook.push({ url, formData });
+ const { url, sendAdditionalPayMetadata } = currentValue.outputData;
+ webhook.push({ url, sendAdditionalPayMetadata, formData });
this.logger.trace(
["StatusService", "outputArgs", "webhookArgs"],
- JSON.stringify({ url, formData })
+ JSON.stringify({ url, sendAdditionalPayMetadata, formData })
);
}
@@ -257,8 +299,6 @@ export class StatusService {
["StatusService", "getViewModel"],
`generating viewModel for ${newReference ?? reference}`
);
- const { customText, components } =
- formModel.def.specialPages?.confirmationPage ?? {};
const referenceToDisplay =
newReference === "UNKNOWN" ? reference : newReference ?? reference;
@@ -269,16 +309,35 @@ export class StatusService {
name: formModel.name,
};
- if (!customText && !callback?.customText) {
+ const confirmationPageDef = formModel.def.specialPages?.confirmationPage;
+ if (!confirmationPageDef?.customText && !callback?.customText) {
return model;
}
+ const customText = { ...confirmationPageDef?.customText };
+
+ if (config.allowUserTemplates) {
+ if (customText?.nextSteps) {
+ customText.nextSteps = nunjucks.renderString(
+ customText.nextSteps,
+ state
+ );
+ }
+ if (customText?.paymentSkipped) {
+ customText.paymentSkipped = nunjucks.renderString(
+ customText.paymentSkipped,
+ state
+ );
+ }
+ }
+
model.customText = {
...customText,
...(callback && callback.customText),
};
- const componentDefsToRender = callback?.components ?? components ?? [];
+ const componentDefsToRender =
+ callback?.components ?? confirmationPageDef?.components ?? [];
const componentCollection = new ComponentCollection(
componentDefsToRender,
formModel
diff --git a/runner/src/server/services/upload/index.ts b/runner/src/server/services/upload/index.ts
new file mode 100644
index 0000000000..4109587a65
--- /dev/null
+++ b/runner/src/server/services/upload/index.ts
@@ -0,0 +1,2 @@
+export { UploadService } from "./uploadService";
+export { MockUploadService } from "./mockUploadService";
diff --git a/runner/src/server/services/upload/mockUploadService.ts b/runner/src/server/services/upload/mockUploadService.ts
new file mode 100644
index 0000000000..b147aefa1b
--- /dev/null
+++ b/runner/src/server/services/upload/mockUploadService.ts
@@ -0,0 +1,20 @@
+import { UploadService } from "./uploadService";
+
+export class MockUploadService extends UploadService {
+ async uploadDocuments(locations: any[]) {
+ const shouldFailOCR = locations.find(
+ (location) => location.hapi.filename === "fails-ocr.png"
+ );
+ const responseData = {
+ res: {
+ statusCode: 201,
+ headers: {
+ location: "https://document-upload-endpoint",
+ },
+ },
+ payload: shouldFailOCR && "imageQualityWarning",
+ };
+
+ return this.parsedDocumentUploadResponse(responseData);
+ }
+}
diff --git a/runner/src/server/services/uploadService.ts b/runner/src/server/services/upload/uploadService.ts
similarity index 82%
rename from runner/src/server/services/uploadService.ts
rename to runner/src/server/services/upload/uploadService.ts
index c1550df1d2..79f49e0907 100644
--- a/runner/src/server/services/uploadService.ts
+++ b/runner/src/server/services/upload/uploadService.ts
@@ -1,9 +1,8 @@
-import http from "http";
import FormData from "form-data";
-import config from "../config";
-import { get, post } from "./httpService";
-import { HapiRequest, HapiResponseToolkit, HapiServer } from "../types";
+import config from "../../config";
+import { get, post } from "../httpService";
+import { HapiRequest, HapiResponseToolkit, HapiServer } from "../../types";
type Payload = HapiRequest["payload"];
@@ -16,6 +15,13 @@ const parsedError = (key: string, error?: string) => {
};
};
+const ERRORS = {
+ fileSizeError: 'The selected file for "%s" is too large',
+ fileTypeError: "Invalid file type. Upload a PNG, JPG or PDF",
+ virusError: 'The selected file for "%s" contained a virus',
+ default: "There was an error uploading your file",
+};
+
export class UploadService {
/**
* Service responsible for uploading files via the FileUploadField. This service has been registered by {@link #createServer}
@@ -27,7 +33,7 @@ export class UploadService {
}
get fileSizeLimit() {
- return 5 * 1024 * 1024; // 5mb
+ return config.maxClientFileSize;
}
get validFiletypes(): ["jpg", "jpeg", "png", "pdf"] {
@@ -55,35 +61,40 @@ export class UploadService {
});
}
- const data = { headers: form.getHeaders(), payload: form };
- const { res } = await post(`${config.documentUploadApiUrl}/v1/files`, data);
- return this.parsedDocumentUploadResponse(res);
+ const requestData = { headers: form.getHeaders(), payload: form };
+ const responseData = await post(
+ `${config.documentUploadApiUrl}/v1/files`,
+ requestData
+ );
+
+ return this.parsedDocumentUploadResponse(responseData);
}
- parsedDocumentUploadResponse(res: http.IncomingMessage) {
+ parsedDocumentUploadResponse({ res, payload }) {
+ const warning = payload?.toString?.();
let error: string | undefined;
let location: string | undefined;
-
switch (res.statusCode) {
case 201:
location = res.headers.location;
break;
+ case 400:
+ error = ERRORS.fileTypeError;
+ break;
case 413:
- error = 'The selected file for "%s" is too large';
+ error = ERRORS.fileSizeError;
break;
case 422:
- error = 'The selected file for "%s" contained a virus';
- break;
- case 400:
- error = "Invalid file type. Upload a PNG, JPG or PDF";
+ error = ERRORS.virusError;
break;
default:
- error = "There was an error uploading your file";
+ error = ERRORS.default;
+ break;
}
-
return {
location,
error,
+ warning,
};
}
@@ -92,7 +103,11 @@ export class UploadService {
return h.continue;
}
- async handleUploadRequest(request: HapiRequest, h: HapiResponseToolkit) {
+ async handleUploadRequest(
+ request: HapiRequest,
+ h: HapiResponseToolkit,
+ page: any
+ ) {
const { cacheService } = request.services([]);
const state = await cacheService.getState(request);
const originalFilenames = state?.originalFilenames ?? {};
@@ -170,10 +185,13 @@ export class UploadService {
if (validFiles.length === values.length) {
try {
- const { error, location } = await this.uploadDocuments(validFiles);
+ const { error, location, warning } = await this.uploadDocuments(
+ validFiles
+ );
if (location) {
originalFilenames[key] = { location };
request.payload[key] = location;
+ request.pre.warning = warning;
}
if (error) {
request.pre.errors = [
@@ -182,8 +200,8 @@ export class UploadService {
];
}
} catch (e) {
- if (e.data && e.data.res) {
- const { error } = this.parsedDocumentUploadResponse(e.data.res);
+ if (e.data?.res) {
+ const { error } = this.parsedDocumentUploadResponse(e.data);
request.pre.errors = [
...(h.request.pre.errors || []),
parsedError(key, error),
diff --git a/runner/src/server/services/webhookService.ts b/runner/src/server/services/webhookService.ts
index f49943a110..a357f2a3ae 100644
--- a/runner/src/server/services/webhookService.ts
+++ b/runner/src/server/services/webhookService.ts
@@ -20,29 +20,32 @@ export class WebhookService {
* @param url - url of the webhook
* @param data - object to send to the webhook
* @param method - POST or PUT request, defaults to POST
+ * @param sendAdditionalPayMetadata - whether to include additional metadata in the request
* @returns object with the property `reference` webhook if the response returns with a reference number. If the call fails, the reference will be 'UNKNOWN'.
*/
async postRequest(
url: string,
data: object,
- method: "POST" | "PUT" = "POST"
+ method: "POST" | "PUT" = "POST",
+ sendAdditionalPayMetadata: boolean = false
) {
this.logger.info(
["WebhookService", "postRequest body"],
JSON.stringify(data)
);
let request = method === "POST" ? post : put;
-
- const { payload } = await request(url, {
- ...DEFAULT_OPTIONS,
- payload: JSON.stringify(data),
- });
-
- if (typeof payload === "object" && !Buffer.isBuffer(payload)) {
- return payload.reference;
- }
-
try {
+ if (!sendAdditionalPayMetadata) {
+ delete data?.metadata?.pay;
+ }
+ const { payload } = await request(url, {
+ ...DEFAULT_OPTIONS,
+ payload: JSON.stringify(data),
+ });
+
+ if (typeof payload === "object" && !Buffer.isBuffer(payload)) {
+ return payload.reference;
+ }
const { reference } = JSON.parse(payload);
this.logger.info(
["WebhookService", "postRequest"],
diff --git a/runner/src/server/templates/additionalContexts.json b/runner/src/server/templates/additionalContexts.json
new file mode 100644
index 0000000000..a531e049cc
--- /dev/null
+++ b/runner/src/server/templates/additionalContexts.json
@@ -0,0 +1,12 @@
+{
+ "example": {
+ "Answer 1": {
+ "additionalInfo": "This content is based on answer 1
",
+ "listItems": "Item 1Item 2"
+ },
+ "Answer 2": {
+ "additionalInfo": "This content is based on answer 2
",
+ "listItems": "Item 3Item 4"
+ }
+ }
+}
diff --git a/runner/src/server/types.ts b/runner/src/server/types.ts
index 7c0bfbe0c6..ecde811e9d 100644
--- a/runner/src/server/types.ts
+++ b/runner/src/server/types.ts
@@ -11,24 +11,26 @@ import { Logger } from "pino";
import { RateOptions } from "./plugins/rateLimit";
import {
CacheService,
- EmailService,
NotifyService,
PayService,
StatusService,
UploadService,
WebhookService,
} from "./services";
+import { QueueStatusService } from "server/services/queueStatusService";
+import { QueueService } from "./services/QueueService";
type Services = (
services: string[]
) => {
cacheService: CacheService;
- emailService: EmailService;
notifyService: NotifyService;
payService: PayService;
uploadService: UploadService;
webhookService: WebhookService;
statusService: StatusService;
+ queueService: QueueService;
+ queueStatusService: QueueStatusService;
};
export type RouteConfig = {
diff --git a/runner/src/server/utils/configSchema.ts b/runner/src/server/utils/configSchema.ts
index 2d54359e80..e2ceaf83db 100644
--- a/runner/src/server/utils/configSchema.ts
+++ b/runner/src/server/utils/configSchema.ts
@@ -31,14 +31,23 @@ export const configSchema = Joi.object({
matomoId: Joi.string().optional(),
matomoUrl: Joi.string().custom(secureUrl).optional(),
payApiUrl: Joi.string().custom(secureUrl),
- payReturnUrl: Joi.string().custom(secureUrl),
+ payReturnUrl: Joi.when("env", {
+ is: Joi.string().valid("development", "test"),
+ then: Joi.string().default("http://localhost:3009"),
+ otherwise: Joi.when("apiEnv", {
+ is: Joi.string().valid("test"),
+ then: Joi.string().default("http://localhost:3009"),
+ otherwise: Joi.string().custom(secureUrl),
+ }),
+ }),
+ payReferenceLength: Joi.number().optional().default(10),
serviceUrl: Joi.string().optional(),
redisHost: Joi.string().optional(),
redisPort: Joi.number().optional(),
redisPassword: Joi.string().optional(),
redisTls: Joi.boolean().optional(),
serviceName: Joi.string().optional(),
- documentUploadApiUrl: Joi.string(),
+ documentUploadApiUrl: Joi.string().allow(null),
previewMode: Joi.boolean().optional(),
enforceCsrf: Joi.boolean().optional(),
sslKey: Joi.string().optional(),
@@ -78,6 +87,54 @@ export const configSchema = Joi.object({
safelist: Joi.array().items(Joi.string()),
initialisedSessionTimeout: Joi.number(),
initialisedSessionKey: Joi.string(),
+ initialisedSessionAlgorithm: Joi.string()
+ .allow(
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "ES256",
+ "ES384",
+ "ES512",
+ "EdDSA",
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "HS256",
+ "HS384",
+ "HS512"
+ )
+ .default("HS512"),
+
+ enableQueueService: Joi.boolean().optional(),
+ queueType: Joi.string().when("enableQueueService", {
+ is: true,
+ then: Joi.required().allow("MYSQL", "PGBOSS").default("MYSQL"),
+ otherwise: Joi.optional().allow(""),
+ }),
+ queueDatabaseUrl: Joi.string().when("enableQueueService", {
+ is: true,
+ then: Joi.required(),
+ otherwise: Joi.optional().allow(""),
+ }),
+ queueServicePollingInterval: Joi.number().when("enableQueueService", {
+ is: true,
+ then: Joi.number().required(),
+ otherwise: Joi.optional(),
+ }),
+ queueServicePollingTimeout: Joi.number().when("enableQueueService", {
+ is: true,
+ then: Joi.number().required(),
+ otherwise: Joi.optional(),
+ }),
+ allowUserTemplates: Joi.boolean().optional(),
+ maxClientFileSize: Joi.number().default("5242880"), // 5MB
+ maxFileSizeStringInMb: Joi.string().default("5"),
});
export function buildConfig(config) {
diff --git a/runner/src/server/views/help/cookies.html b/runner/src/server/views/help/cookies.html
index f6199da99e..7c9d033632 100644
--- a/runner/src/server/views/help/cookies.html
+++ b/runner/src/server/views/help/cookies.html
@@ -1,4 +1,6 @@
{% extends 'layout.html' %}
+{% from "radios/macro.njk" import govukRadios %}
+{% from "button/macro.njk" import govukButton %}
{% block content %}
@@ -20,14 +22,14 @@
Your progress when using this service
- session |
- Set to remember information you’ve entered into a form |
+ cookies_policy |
+ Saves your cookie consent settings |
When you close your browser |
- seen_cookie |
- Tracks whether you have seen the cookie banner and doesn't show it again if you have |
- 28 days |
+ session |
+ Set to remember information you’ve entered into a form |
+ When you close your browser |
crumb |
@@ -37,11 +39,66 @@ Your progress when using this service
+ {% if gtmId1 or gtmId2 %}
+
+ {% endif %}
{% endblock %}
diff --git a/runner/src/server/views/layout.html b/runner/src/server/views/layout.html
index 934852b85a..289143fe0b 100644
--- a/runner/src/server/views/layout.html
+++ b/runner/src/server/views/layout.html
@@ -97,6 +97,7 @@
{{ pageTitle }}
{% endblock %}
+
{% block skipLink %}
{{
govukSkipLink({
@@ -106,6 +107,7 @@
}}
{% endblock %}
+
{% block header %}