diff --git a/package-lock.json b/package-lock.json index 54c5fdd..9a67e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "OGL-UK-3.0", "dependencies": { "@aws-sdk/client-sqs": "3.894.0", - "@defra/forms-engine-plugin": "3.0.4", - "@defra/forms-model": "3.0.552", + "@defra/forms-engine-plugin": "3.0.9", + "@defra/forms-model": "3.0.559", "@defra/hapi-tracing": "^1.28.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/hapi": "^21.4.2", @@ -2308,13 +2308,13 @@ "license": "MIT" }, "node_modules/@defra/forms-engine-plugin": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.4.tgz", - "integrity": "sha512-aPCE3M22achepzyJum8Su1CdQpyTw71AdD5ee878PL8/Xf1EWceUdSyykOpdbBTQGQHIia0lv9wNqbMjF5d4mg==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-3.0.9.tgz", + "integrity": "sha512-RRAycdYqBwjoPqDwawIEzSab2QZjtl8wtvQPvurKGDRD2ZMbAZg44LKd3X9RKRW5eztdLs+deGgJkBPVdSVhdw==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.552", + "@defra/forms-model": "^3.0.559", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2439,9 +2439,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.552", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.552.tgz", - "integrity": "sha512-g2ZpUHL+CQyJbGX1kBpM7pXy33GVKMaS/95it0tJXkkNECO+0pkBdvu2EVrUD6GMHAirvWzhvFVA0y1CX8rP3g==", + "version": "3.0.559", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", + "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index d7d5932..51cbdc2 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ }, "dependencies": { "@aws-sdk/client-sqs": "3.894.0", - "@defra/forms-engine-plugin": "3.0.4", - "@defra/forms-model": "3.0.552", + "@defra/forms-engine-plugin": "3.0.9", + "@defra/forms-model": "3.0.559", "@defra/hapi-tracing": "^1.28.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/hapi": "^21.4.2", diff --git a/src/config/index.js b/src/config/index.js index 4f78e8b..26aa073 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -127,13 +127,13 @@ export const config = convict({ designerUrl: { doc: 'URL to call Forms Designer', format: String, - default: null, + default: '', env: 'DESIGNER_URL' }, managerUrl: { doc: 'URL to call Forms Manager API', format: String, - default: null, + default: '', env: 'MANAGER_URL' }, isSecureContextEnabled: { diff --git a/src/lib/manager.js b/src/lib/manager.js index d286781..d4ed5e3 100644 --- a/src/lib/manager.js +++ b/src/lib/manager.js @@ -12,9 +12,7 @@ const managerUrl = config.get('managerUrl') * @returns {Promise} */ export async function getFormDefinition(formId, formStatus, versionNumber) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!managerUrl) { - // TS / eslint conflict throw new Error('Missing MANAGER_URL') } @@ -33,5 +31,22 @@ export async function getFormDefinition(formId, formStatus, versionNumber) { } /** - * @import { FormDefinition } from '@defra/forms-model' + * Gets the form metadata from the Forms Manager API + * @param {string} formId + * @returns {Promise} + */ +export async function getFormMetadata(formId) { + if (!managerUrl) { + throw new Error('Missing MANAGER_URL') + } + + const formUrl = new URL(`/forms/${formId}`, managerUrl) + + const { body } = await getJson(formUrl) + + return body +} + +/** + * @import { FormDefinition, FormMetadata } from '@defra/forms-model' */ diff --git a/src/lib/manager.test.js b/src/lib/manager.test.js index bc46d24..f99e089 100644 --- a/src/lib/manager.test.js +++ b/src/lib/manager.test.js @@ -1,8 +1,8 @@ import { FormStatus } from '@defra/forms-model' -import { buildDefinition } from '@defra/forms-model/stubs' +import { buildDefinition, buildMetaData } from '@defra/forms-model/stubs' import { getJson } from '~/src/lib/fetch.js' -import { getFormDefinition } from '~/src/lib/manager.js' +import { getFormDefinition, getFormMetadata } from '~/src/lib/manager.js' jest.mock('~/src/lib/fetch.js') jest.mock('~/src/config/index.js', () => ({ config: { @@ -10,102 +10,140 @@ jest.mock('~/src/config/index.js', () => ({ } })) -describe('getDefinition', () => { - it('should get the current definition if draft', async () => { - const expectedDefinition = buildDefinition() - const formId = '68a890909ab460290c289409' - jest - .mocked(getJson) - .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) - const definition = await getFormDefinition( - formId, - FormStatus.Draft, - undefined - ) - expect(getJson).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://forms-manager/forms/68a890909ab460290c289409/definition/draft' - }) - ) - expect(definition).toEqual(expectedDefinition) - }) +describe('Manager', () => { + describe('getDefinition', () => { + it('should get the current definition if draft', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Draft, + undefined + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/definition/draft' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) - it('should get the current definition if live', async () => { - const expectedDefinition = buildDefinition() - const formId = '68a890909ab460290c289409' - jest - .mocked(getJson) - .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) - const definition = await getFormDefinition( - formId, - FormStatus.Live, - undefined - ) - expect(getJson).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://forms-manager/forms/68a890909ab460290c289409/definition/' - }) - ) - expect(definition).toEqual(expectedDefinition) - }) + it('should get the current definition if live', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Live, + undefined + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/definition/' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) - it('should get a specific version if version number is provided', async () => { - const expectedDefinition = buildDefinition() - const formId = '68a890909ab460290c289409' - const versionNumber = 3 - jest - .mocked(getJson) - .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) - const definition = await getFormDefinition( - formId, - FormStatus.Live, - versionNumber - ) - expect(getJson).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/3/definition' - }) - ) - expect(definition).toEqual(expectedDefinition) - }) + it('should get a specific version if version number is provided', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + const versionNumber = 3 + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Live, + versionNumber + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/3/definition' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) + + it('should get a specific version with draft status if version number is provided', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + const versionNumber = 1 + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Draft, + versionNumber + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/1/definition' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) + + it('should handle version number 0 correctly', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + const versionNumber = 0 + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Live, + versionNumber + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/0/definition' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) - it('should get a specific version with draft status if version number is provided', async () => { - const expectedDefinition = buildDefinition() - const formId = '68a890909ab460290c289409' - const versionNumber = 1 - jest - .mocked(getJson) - .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) - const definition = await getFormDefinition( - formId, - FormStatus.Draft, - versionNumber - ) - expect(getJson).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/1/definition' - }) - ) - expect(definition).toEqual(expectedDefinition) + it('should throw if no manager url set', async () => { + const expectedDefinition = buildDefinition() + const formId = '68a890909ab460290c289409' + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) + const definition = await getFormDefinition( + formId, + FormStatus.Draft, + undefined + ) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409/definition/draft' + }) + ) + expect(definition).toEqual(expectedDefinition) + }) }) - it('should handle version number 0 correctly', async () => { - const expectedDefinition = buildDefinition() - const formId = '68a890909ab460290c289409' - const versionNumber = 0 - jest - .mocked(getJson) - .mockResolvedValueOnce({ response: {}, body: expectedDefinition }) - const definition = await getFormDefinition( - formId, - FormStatus.Live, - versionNumber - ) - expect(getJson).toHaveBeenCalledWith( - expect.objectContaining({ - href: 'http://forms-manager/forms/68a890909ab460290c289409/versions/0/definition' - }) - ) - expect(definition).toEqual(expectedDefinition) + describe('getMetadata', () => { + it('should get the metadata', async () => { + const expectedMetadata = buildMetaData() + const formId = '68a890909ab460290c289409' + jest + .mocked(getJson) + .mockResolvedValueOnce({ response: {}, body: expectedMetadata }) + const metadata = await getFormMetadata(formId) + expect(getJson).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'http://forms-manager/forms/68a890909ab460290c289409' + }) + ) + expect(metadata).toEqual(expectedMetadata) + }) }) }) diff --git a/src/service/index.js b/src/service/index.js index c69e1df..65878c7 100644 --- a/src/service/index.js +++ b/src/service/index.js @@ -1,5 +1,5 @@ import { handleFormSubmissionEvents } from '~/src/service/events.js' -import { sendNotifyEmail } from '~/src/service/notify.js' +import { sendNotifyEmails } from '~/src/service/notify.js' /** * @param {Message[]} messages @@ -7,7 +7,7 @@ import { sendNotifyEmail } from '~/src/service/notify.js' */ export async function handleEvent(messages) { const service = { - handleFormSubmission: sendNotifyEmail + handleFormSubmission: sendNotifyEmails } return handleFormSubmissionEvents(messages, service) } diff --git a/src/service/mappers/user-confirmation.js b/src/service/mappers/user-confirmation.js new file mode 100644 index 0000000..d2172c0 --- /dev/null +++ b/src/service/mappers/user-confirmation.js @@ -0,0 +1,44 @@ +import { escapeMarkdown } from '@defra/forms-engine-plugin/engine/components/helpers/index.js' + +import { format as dateFormat } from '~/src/helpers/date.js' + +/** + * @param {string} formName + * @param {Date} submissionDate + * @param {FormMetadata} metadata + */ +export function getUserConfirmationEmailBody( + formName, + submissionDate, + metadata +) { + const formattedSubmissionDate = `${dateFormat(submissionDate, 'h:mmaaa')} on ${dateFormat(submissionDate, 'eeee d MMMM yyyy')}` + + const { submissionGuidance, organisation, contact } = metadata + + const phoneDetails = contact?.phone ? `${contact.phone}\n\n` : '' + const emailDetails = contact?.email + ? `[${contact.email.address}](mailto:${contact.email.address})\n${contact.email.responseTime}\n\n` + : '' + const onlineDetails = contact?.online + ? `[${contact.online.text}](${contact.online.url})\n\n` + : '' + const contactDetails = `${phoneDetails}${emailDetails}${onlineDetails}` + + return ` +# We have your form +We received your form submission for ‘${formName}’ on ${formattedSubmissionDate}. + +## What happens next +${submissionGuidance} + +## Get help +${contactDetails}Do not reply to this emall. We do not monitor reples to this email address. + +From ${escapeMarkdown(organisation)} +` +} + +/** + * @import { FormMetadata } from '@defra/forms-model' + */ diff --git a/src/service/mappers/user-confirmation.test.js b/src/service/mappers/user-confirmation.test.js new file mode 100644 index 0000000..dc35b8d --- /dev/null +++ b/src/service/mappers/user-confirmation.test.js @@ -0,0 +1,93 @@ +import { buildMetaData } from '@defra/forms-model/stubs' + +import { getUserConfirmationEmailBody } from '~/src/service/mappers/user-confirmation.js' + +describe('user-confirmation', () => { + test('should handle general email content', () => { + const formName = 'My Form Name' + const submissionDate = new Date('2025-11-04T14:21:35+00:00') + const metadata = buildMetaData({ + submissionGuidance: 'Some submission guidance' + }) + expect( + getUserConfirmationEmailBody(formName, submissionDate, metadata) + ).toBe( + ` +# We have your form +We received your form submission for ‘My Form Name’ on 2:21pm on Tuesday 4 November 2025. + +## What happens next +Some submission guidance + +## Get help +Do not reply to this emall. We do not monitor reples to this email address. + +From Defra +` + ) + }) + + test('should handle time shift - plus 1 hour', () => { + const formName = 'My Form Name' + const submissionDate = new Date('2025-11-04T14:21:35+01:00') + const metadata = buildMetaData({ + submissionGuidance: 'Some submission guidance' + }) + expect( + getUserConfirmationEmailBody(formName, submissionDate, metadata) + ).toContain(' on 1:21pm on Tuesday 4 November 2025.') + }) + + test('should handle time shift - plus 1 hour in BST', () => { + const formName = 'My Form Name' + const submissionDate = new Date('2025-05-04T14:21:35+01:00') + const metadata = buildMetaData({ + submissionGuidance: 'Some submission guidance' + }) + expect( + getUserConfirmationEmailBody(formName, submissionDate, metadata) + ).toContain(' on 2:21pm on Sunday 4 May 2025.') + }) + + test('should handle contact details', () => { + const formName = 'My Form Name' + const submissionDate = new Date('2025-11-04T14:21:35+00:00') + const metadata = buildMetaData({ + submissionGuidance: 'Some submission guidance', + contact: { + phone: '0121 123456789', + email: { + address: 'our-email@test.com', + responseTime: 'We will respond within 5 working days' + }, + online: { + url: 'https://some-online-help.com', + text: 'This is our online url' + } + } + }) + expect( + getUserConfirmationEmailBody(formName, submissionDate, metadata) + ).toBe( + ` +# We have your form +We received your form submission for ‘My Form Name’ on 2:21pm on Tuesday 4 November 2025. + +## What happens next +Some submission guidance + +## Get help +0121 123456789 + +[our-email@test.com](mailto:our-email@test.com) +We will respond within 5 working days + +[This is our online url](https://some-online-help.com) + +Do not reply to this emall. We do not monitor reples to this email address. + +From Defra +` + ) + }) +}) diff --git a/src/service/notify.js b/src/service/notify.js index c58d1d8..ae32d6d 100644 --- a/src/service/notify.js +++ b/src/service/notify.js @@ -1,23 +1,60 @@ import { escapeMarkdown } from '@defra/forms-engine-plugin/engine/components/helpers/index.js' -import { getErrorMessage } from '@defra/forms-model' +import { ControllerType, getErrorMessage } from '@defra/forms-model' import { config } from '~/src/config/index.js' import { createLogger } from '~/src/helpers/logging/logger.js' -import { getFormDefinition } from '~/src/lib/manager.js' +import { getFormDefinition, getFormMetadata } from '~/src/lib/manager.js' import { sendNotification } from '~/src/lib/notify.js' import { getFormatter } from '~/src/service/mappers/formatters/index.js' +import { getUserConfirmationEmailBody } from '~/src/service/mappers/user-confirmation.js' // @ts-expect-error - incorrect typings in convict const templateId = /** @type {string} */ (config.get('notifyTemplateId')) const logger = createLogger() +// TODO - need a better way to handle custom controllers in the output formatters +/** + * Revert any custom controllers to their parent/base class since engine-plugin has no knowledge of them + * @param {FormDefinition} definition + * @returns {FormDefinition} + */ +export function removeCustomControllers(definition) { + return { + ...definition, + pages: definition.pages.map((page) => { + if (page.controller) { + const controller = [ + 'SummaryPageWithConfirmationEmailController' + ].includes(page.controller) + ? ControllerType.Summary + : page.controller + return /** @type {Page} */ ({ + ...page, + controller + }) + } + return page + }) + } +} + /** * Sends a mail to notify * @param {FormAdapterSubmissionMessage} formSubmissionMessage * @returns {Promise} */ -export async function sendNotifyEmail(formSubmissionMessage) { +export async function sendNotifyEmails(formSubmissionMessage) { + await sendInternalEmail(formSubmissionMessage) + await sendUserConfirmationEmail(formSubmissionMessage) +} + +/** + * Sends an internal email to notify (to the form's submission inbox) + * @param {FormAdapterSubmissionMessage} formSubmissionMessage + * @returns {Promise} + */ +export async function sendInternalEmail(formSubmissionMessage) { const { formName: formNameInput, formId, @@ -29,18 +66,23 @@ export async function sendNotifyEmail(formSubmissionMessage) { const logTags = ['submit', 'email'] // Get submission email personalisation - logger.info(logTags, 'Getting personalisation data') + logger.info( + logTags, + 'Getting personalisation data - internal submission email' + ) logger.debug( - `Getting form definition: ${formId} version: ${versionMetadata?.versionNumber}` + `Getting form definition: ${formId} version: ${versionMetadata?.versionNumber} - internal submission email` ) - const definition = await getFormDefinition( + const origDefinition = await getFormDefinition( formId, status, versionMetadata?.versionNumber ) + const definition = removeCustomControllers(origDefinition) + const formName = escapeMarkdown(formNameInput) const subject = isPreview ? `TEST FORM SUBMISSION: ${formName}` @@ -58,7 +100,7 @@ export async function sendNotifyEmail(formSubmissionMessage) { body = Buffer.from(body).toString('base64') } - logger.info(logTags, 'Sending email') + logger.info(logTags, 'Sending internal submission email') try { // Send submission email @@ -71,17 +113,81 @@ export async function sendNotifyEmail(formSubmissionMessage) { } }) - logger.info(logTags, 'Email sent successfully') + logger.info(logTags, 'Internal submission email sent successfully') } catch (err) { const errMsg = getErrorMessage(err) logger.error( err, - `[emailSendFailed] Error sending notification email - templateId: ${templateId} - ${errMsg}` + `[emailSendFailed] Error sending internal submission email - templateId: ${templateId} - ${errMsg}` ) throw err } } + +/** + * Sends a confirmation email to the submitting user + * @param {FormAdapterSubmissionMessage} formSubmissionMessage + * @returns {Promise} + */ +export async function sendUserConfirmationEmail(formSubmissionMessage) { + const { + formId, + formName: formNameInput, + isPreview, + custom + } = formSubmissionMessage.meta + + const userConfirmationEmail = custom?.userConfirmationEmail + + if (!userConfirmationEmail) { + // Don't send confirmation email if no email address passed in the message + return + } + + const logTags = ['submit', 'email'] + + // Get submission email personalisation + logger.info(logTags, 'Getting personalisation data - user confirmation email') + + const formName = escapeMarkdown(formNameInput) + + const metadata = await getFormMetadata(formId) + + const subject = isPreview + ? `TEST FORM CONFIRMATION: ${metadata.organisation}` + : `Form submitted to ${metadata.organisation}` + + logger.info(logTags, 'Sending user confirmation email') + + if (!metadata.submissionGuidance) { + throw new Error(`Missing submission guidance for form id ${formId}`) + } + + try { + // Send confirmation email + await sendNotification({ + templateId, + emailAddress: /** @type {string} */ (userConfirmationEmail), + personalisation: { + subject, + body: getUserConfirmationEmailBody(formName, new Date(), metadata) + } + }) + + logger.info(logTags, 'User confirmation email sent successfully') + } catch (err) { + const errMsg = getErrorMessage(err) + logger.error( + err, + `[emailSendFailed] Error sending user confirmation email - templateId: ${templateId} - ${errMsg}` + ) + + throw err + } +} + /** + * @import { FormDefinition, Page } from '@defra/forms-model' * @import { FormAdapterSubmissionMessage } from '@defra/forms-engine-plugin/engine/types.js' */ diff --git a/src/service/notify.test.js b/src/service/notify.test.js index e402491..92ffd48 100644 --- a/src/service/notify.test.js +++ b/src/service/notify.test.js @@ -5,9 +5,9 @@ import { FormStatus, SchemaVersion } from '@defra/forms-model' -import { buildDefinition } from '@defra/forms-model/stubs' +import { buildDefinition, buildMetaData } from '@defra/forms-model/stubs' -import { getFormDefinition } from '~/src/lib/manager.js' +import { getFormDefinition, getFormMetadata } from '~/src/lib/manager.js' import { sendNotification } from '~/src/lib/notify.js' import { buildFormAdapterSubmissionMessage, @@ -15,7 +15,10 @@ import { buildFormAdapterSubmissionMessageMetaStub, buildFormAdapterSubmissionMessageResult } from '~/src/service/__stubs__/event-builders.js' -import { sendNotifyEmail } from '~/src/service/notify.js' +import { + sendNotifyEmails, + sendUserConfirmationEmail +} from '~/src/service/notify.js' jest.mock('~/src/helpers/logging/logger.js', () => ({ createLogger: () => ({ @@ -113,7 +116,7 @@ describe('notify', () => { lists: [] }) - describe('sendNotifyEmail', () => { + describe('sendNotifyEmails', () => { it('should send a v1 machine readable email', async () => { const definition = buildDefinition({ ...baseDefinition, @@ -123,7 +126,7 @@ describe('notify', () => { } }) jest.mocked(getFormDefinition).mockResolvedValueOnce(definition) - await sendNotifyEmail(formAdapterSubmissionMessage) + await sendNotifyEmails(formAdapterSubmissionMessage) const [sendNotificationCall] = jest.mocked(sendNotification).mock.calls[0] expect(sendNotificationCall).toEqual({ @@ -155,7 +158,7 @@ describe('notify', () => { it('should send a v2 machine readable email', async () => { jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) - await sendNotifyEmail(formAdapterSubmissionMessage) + await sendNotifyEmails(formAdapterSubmissionMessage) const [sendNotificationCall] = jest.mocked(sendNotification).mock.calls[0] expect(sendNotificationCall).toEqual({ @@ -627,7 +630,7 @@ describe('notify', () => { }) jest.mocked(getFormDefinition).mockResolvedValueOnce(definition) - await sendNotifyEmail(formAdapterSubmissionMessage) + await sendNotifyEmails(formAdapterSubmissionMessage) expect(getFormDefinition).toHaveBeenCalledWith( formId, FormStatus.Live, @@ -643,12 +646,104 @@ describe('notify', () => { }) }) + it('should send a user confirmation email', async () => { + jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) + jest.mocked(getFormMetadata).mockResolvedValueOnce( + buildMetaData({ + submissionGuidance: 'Some guidance text' + }) + ) + const formAdapterMessageWithUserEmail = structuredClone( + formAdapterSubmissionMessage + ) + formAdapterMessageWithUserEmail.meta.custom = { + userConfirmationEmail: 'my-email@test.com' + } + await sendNotifyEmails(formAdapterMessageWithUserEmail) + + expect(jest.mocked(sendNotification)).toHaveBeenCalledTimes(2) + const [sendNotificationCall] = jest.mocked(sendNotification).mock.calls[0] + expect(sendNotificationCall).toEqual({ + templateId: 'notify-template-id-1', + emailAddress: 'notificationEmail@example.uk', + personalisation: { + subject: 'Form submission: Machine readable form', + body: expect.any(String) + } + }) + const [sendConfirmationCall] = jest.mocked(sendNotification).mock.calls[1] + expect(sendConfirmationCall).toEqual({ + templateId: 'notify-template-id-1', + emailAddress: 'my-email@test.com', + personalisation: { + subject: 'Form submitted to Defra', + body: expect.any(String) + } + }) + const sendNotificationBody = JSON.parse( + Buffer.from( + sendNotificationCall.personalisation.body, + 'base64' + ).toString('utf-8') + ) + expect(new Date(sendNotificationBody.meta.timestamp)).not.toBeNaN() + expect(sendNotificationBody).toEqual({ + meta: { + schemaVersion: '2', + timestamp: expect.any(String), + referenceNumber, + definition: baseDefinition + }, + data: formSubmissionData + }) + }) + + it('should throw if confirmation email has no submission guidance set', async () => { + jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) + jest.mocked(getFormMetadata).mockResolvedValueOnce( + buildMetaData({ + submissionGuidance: undefined + }) + ) + const formAdapterMessageWithUserEmail = structuredClone( + formAdapterSubmissionMessage + ) + formAdapterMessageWithUserEmail.meta.custom = { + userConfirmationEmail: 'my-email@test.com' + } + await expect(() => + sendNotifyEmails(formAdapterMessageWithUserEmail) + ).rejects.toThrow( + 'Missing submission guidance for form id 68a8b0449ab460290c28940a' + ) + }) + + it('confirmation email should handle and throw errors', async () => { + const err = new Error('Upstream failure') + jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) + jest.mocked(getFormMetadata).mockResolvedValueOnce( + buildMetaData({ + submissionGuidance: 'Some guidance text' + }) + ) + const formAdapterMessageWithUserEmail = structuredClone( + formAdapterSubmissionMessage + ) + formAdapterMessageWithUserEmail.meta.custom = { + userConfirmationEmail: 'my-email@test.com' + } + jest.mocked(sendNotification).mockRejectedValueOnce(err) + await expect( + sendUserConfirmationEmail(formAdapterMessageWithUserEmail) + ).rejects.toThrow(err) + }) + it('should handle and throw errors', async () => { const err = new Error('Upstream failure') jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) jest.mocked(sendNotification).mockRejectedValueOnce(err) await expect( - sendNotifyEmail(formAdapterSubmissionMessage) + sendNotifyEmails(formAdapterSubmissionMessage) ).rejects.toThrow(err) }) @@ -677,7 +772,7 @@ describe('notify', () => { }) jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) - await sendNotifyEmail(versionedFormAdapterSubmissionMessage) + await sendNotifyEmails(versionedFormAdapterSubmissionMessage) expect(getFormDefinition).toHaveBeenCalledWith( formId, @@ -688,7 +783,7 @@ describe('notify', () => { it('should use default form definition when versionMetadata is not present', async () => { jest.mocked(getFormDefinition).mockResolvedValueOnce(baseDefinition) - await sendNotifyEmail(formAdapterSubmissionMessage) + await sendNotifyEmails(formAdapterSubmissionMessage) expect(getFormDefinition).toHaveBeenCalledWith( formId,