From 173b3d77ff496d05486d7294e815ca8e69754564 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 21 Aug 2025 12:30:13 +0100 Subject: [PATCH 01/12] feat: export types --- package.json | 5 ++ .../engine/outputFormatters/machine/v2.ts | 2 +- src/server/plugins/engine/types/index.ts | 84 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/server/plugins/engine/types/index.ts diff --git a/package.json b/package.json index 1fcf2e7f2..9199f91f8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,11 @@ "import": "./.server/server/plugins/engine/index.js", "default": "./.server/server/plugins/engine/index.js" }, + "./types": { + "types": "./.server/server/plugins/engine/types/index.d.ts", + "import": "./.server/server/plugins/engine/types/index.js", + "default": "./.server/server/plugins/engine/types/index.js" + }, "./shared.js": "./.server/client/javascripts/shared.js", "./shared.min.js": "./.public/javascripts/shared.min.js", "./shared.min.js.map": "./.public/javascripts/shared.min.js.map", diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index f0c544789..5e2586a8e 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -150,7 +150,7 @@ type FileUploadFieldDetailitem = Omit & { field: FileUploadField } -type RichFormValue = +export type RichFormValue = | FormValue | FormPayload | DatePartsState diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts new file mode 100644 index 000000000..297d0d2d0 --- /dev/null +++ b/src/server/plugins/engine/types/index.ts @@ -0,0 +1,84 @@ +export type { + CheckAnswers, + ErrorMessageTemplate, + ErrorMessageTemplateList, + FeaturedFormPageViewModel, + FileState, + FilterFunction, + FormContext, + FormContextRequest, + FormPageViewModel, + FormPayload, + FormPayloadParams, + FormState, + FormStateValue, + FormSubmissionError, + FormSubmissionState, + FormValidationResult, + FormValue, + GlobalFunction, + ItemDeletePageViewModel, + OnRequestCallback, + PageViewModel, + PageViewModelBase, + PluginOptions, + PreparePageEventRequestOptions, + RepeatItemState, + RepeatListState, + RepeaterSummaryPageViewModel, + SummaryList, + SummaryListAction, + SummaryListRow, + TempFileState, + UploadInitiateResponse, + UploadStatusFileResponse, + UploadStatusResponse +} from '~/src/server/plugins/engine/types.js' + +export { FileStatus, UploadStatus } from '~/src/server/plugins/engine/types.js' + +export type { + Detail, + DetailItem, + DetailItemBase, + DetailItemField, + DetailItemRepeat, + ExecutableCondition +} from '~/src/server/plugins/engine/models/types.js' + +export type { + BackLink, + ComponentText, + ComponentViewModel, + Content, + DateInputItem, + DatePartsState, + Label, + ListItem, + ListItemLabel, + MonthYearState, + ViewModel +} from '~/src/server/plugins/engine/components/types.js' + +export type { UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js' + +export type { + FormParams, + FormQuery, + FormRequest, + FormRequestPayload, + FormRequestPayloadRefs, + FormRequestRefs +} from '~/src/server/routes/types.js' + +export { FormAction, FormStatus } from '~/src/server/routes/types.js' + +export type { + FormSubmissionService, + FormsService, + OutputService, + RouteConfig, + Services +} from '~/src/server/types.js' + +export type { RichFormValue } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' From 4bbf9a09d56b1cca83eda72bf252f64bcb921a1a Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 21 Aug 2025 12:31:56 +0100 Subject: [PATCH 02/12] refactor: remove notify logic and remove definition from payload --- src/config/index.ts | 15 -- .../engine/outputFormatters/machine/v1.ts | 3 +- src/server/plugins/engine/services/index.js | 33 +++- .../engine/services/notifyService.test.ts | 155 ------------------ .../plugins/engine/services/notifyService.ts | 69 -------- src/server/utils/notify.test.ts | 37 ----- src/server/utils/notify.ts | 50 ------ 7 files changed, 34 insertions(+), 328 deletions(-) delete mode 100644 src/server/plugins/engine/services/notifyService.test.ts delete mode 100644 src/server/plugins/engine/services/notifyService.ts delete mode 100644 src/server/utils/notify.test.ts delete mode 100644 src/server/utils/notify.ts diff --git a/src/config/index.ts b/src/config/index.ts index e6300f769..f933b79ce 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -161,21 +161,6 @@ export const config = convict({ } as SchemaObj }, - /** - * Email outputs - * Email outputs will use notify to send an email to a single inbox. - */ - notifyTemplateId: { - format: String, - default: '', - env: 'NOTIFY_TEMPLATE_ID' - } as SchemaObj, - notifyAPIKey: { - format: String, - default: '', - env: 'NOTIFY_API_KEY' - } as SchemaObj, - /** * API integrations */ diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index e888d428f..078cb1a93 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -30,7 +30,8 @@ export function format( schemaVersion: '1', timestamp: now.toISOString(), referenceNumber: context.referenceNumber, - definition: model.def + formId: model.def.name, + formName: model.name }, data: categorisedData } diff --git a/src/server/plugins/engine/services/index.js b/src/server/plugins/engine/services/index.js index b990525f2..833c79336 100644 --- a/src/server/plugins/engine/services/index.js +++ b/src/server/plugins/engine/services/index.js @@ -1,3 +1,34 @@ export * as formsService from '~/src/server/plugins/engine/services/formsService.js' export * as formSubmissionService from '~/src/server/plugins/engine/services/formSubmissionService.js' -export * as outputService from '~/src/server/plugins/engine/services/notifyService.js' + +export const outputService = { + /** + * @param {FormContext} _context + * @param {FormRequestPayload} _request + * @param {FormModel} _model + * @param {string} _emailAddress + * @param {DetailItem[]} _items + * @param {SubmitResponsePayload} _submitResponse + * @returns {Promise} + */ + async submit( + _context, + _request, + _model, + _emailAddress, + _items, + _submitResponse + ) { + // No-op: Notification functionality has been moved to another service + // TODO: come back to this + return Promise.resolve() + } +} + +/** + * @import { FormContext } from '~/src/server/plugins/engine/types.js' + * @import { FormRequestPayload } from '~/src/server/routes/types.js' + * @import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' + * @import { DetailItem } from '~/src/server/plugins/engine/models/types.js' + * @import { SubmitResponsePayload } from '@defra/forms-model' + */ diff --git a/src/server/plugins/engine/services/notifyService.test.ts b/src/server/plugins/engine/services/notifyService.test.ts deleted file mode 100644 index f382b3c08..000000000 --- a/src/server/plugins/engine/services/notifyService.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js' -import { type FormModel } from '~/src/server/plugins/engine/models/index.js' -import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' -import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js' -import { submit } from '~/src/server/plugins/engine/services/notifyService.js' -import { type FormContext } from '~/src/server/plugins/engine/types.js' -import { - FormStatus, - type FormRequestPayload -} from '~/src/server/routes/types.js' -import { sendNotification } from '~/src/server/utils/notify.js' - -jest.mock('~/src/server/utils/notify') -jest.mock('~/src/server/plugins/engine/helpers') -jest.mock('~/src/server/plugins/engine/outputFormatters/index') - -describe('notifyService', () => { - const submitResponse = { - message: 'Submit completed', - result: { - files: { - main: '00000000-0000-0000-0000-000000000000', - repeaters: { - pizza: '11111111-1111-1111-1111-111111111111' - } - } - } - } - - const items: DetailItem[] = [] - - const mockRequest: FormRequestPayload = jest.mocked({ - path: 'test', - logger: { - info: jest.fn() - } - } as unknown as FormRequestPayload) - let model: FormModel - const sendNotificationMock = jest.mocked(sendNotification) - const formContext = {} as FormContext - - beforeEach(() => { - jest.resetAllMocks() - }) - - it('creates a subject line for real forms', async () => { - model = { - name: 'foobar', - def: { - output: { - audience: 'human', - version: '1' - } - } - } as FormModel - - jest.mocked(checkFormStatus).mockReturnValue({ - isPreview: false, - state: FormStatus.Draft - }) - jest.mocked(getFormatter).mockReturnValue(() => 'dummy-live') - - await submit( - formContext, - mockRequest, - model, - 'test@defra.gov.uk', - items, - submitResponse - ) - - expect(sendNotificationMock).toHaveBeenCalledWith( - expect.objectContaining({ - personalisation: { - subject: `Form submission: foobar`, - body: 'dummy-live' - } - }) - ) - }) - - it('creates a subject line for preview forms', async () => { - model = { - name: 'foobar', - def: { - output: { - audience: 'human', - version: '1' - } - } - } as FormModel - - jest.mocked(checkFormStatus).mockReturnValue({ - isPreview: true, - state: FormStatus.Draft - }) - jest.mocked(getFormatter).mockReturnValue(() => 'dummy-preview') - - await submit( - formContext, - mockRequest, - model, - 'test@defra.gov.uk', - items, - submitResponse - ) - - expect(sendNotificationMock).toHaveBeenCalledWith( - expect.objectContaining({ - personalisation: { - subject: `TEST FORM SUBMISSION: foobar`, - body: 'dummy-preview' - } - }) - ) - }) - - it('base64 encodes form data when aimed at machines', async () => { - model = { - name: 'foobar', - def: { - output: { - audience: 'machine', - version: '1' - } - } - } as FormModel - - jest.mocked(checkFormStatus).mockReturnValue({ - isPreview: true, - state: FormStatus.Draft - }) - jest - .mocked(getFormatter) - .mockReturnValue(() => 'dummy-preview " Hello world \' !@/') - - await submit( - formContext, - mockRequest, - model, - 'test@defra.gov.uk', - items, - submitResponse - ) - - expect(sendNotificationMock).toHaveBeenCalledWith( - expect.objectContaining({ - personalisation: { - subject: `TEST FORM SUBMISSION: foobar`, - body: 'ZHVtbXktcHJldmlldyAiIEhlbGxvIHdvcmxkICcgIUAv' - } - }) - ) - }) -}) diff --git a/src/server/plugins/engine/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts deleted file mode 100644 index 7b99b8e5d..000000000 --- a/src/server/plugins/engine/services/notifyService.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getErrorMessage, type SubmitResponsePayload } from '@defra/forms-model' - -import { config } from '~/src/config/index.js' -import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers.js' -import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js' -import { type FormModel } from '~/src/server/plugins/engine/models/index.js' -import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' -import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js' -import { type FormContext } from '~/src/server/plugins/engine/types.js' -import { type FormRequestPayload } from '~/src/server/routes/types.js' -import { sendNotification } from '~/src/server/utils/notify.js' - -const templateId = config.get('notifyTemplateId') - -export async function submit( - context: FormContext, - request: FormRequestPayload, - model: FormModel, - emailAddress: string, - items: DetailItem[], - submitResponse: SubmitResponsePayload -) { - const logTags = ['submit', 'email'] - const formStatus = checkFormStatus(request.params) - - // Get submission email personalisation - request.logger.info(logTags, 'Getting personalisation data') - - const formName = escapeMarkdown(model.name) - const subject = formStatus.isPreview - ? `TEST FORM SUBMISSION: ${formName}` - : `Form submission: ${formName}` - - const outputAudience = model.def.output?.audience ?? 'human' - const outputVersion = model.def.output?.version ?? '1' - - const outputFormatter = getFormatter(outputAudience, outputVersion) - let body = outputFormatter(context, items, model, submitResponse, formStatus) - - // GOV.UK Notify transforms quotes into curly quotes, so we can't just send the raw payload - // This is logic specific to Notify, so we include the logic here rather than in the formatter - if (outputAudience === 'machine') { - body = Buffer.from(body).toString('base64') - } - - request.logger.info(logTags, 'Sending email') - - try { - // Send submission email - await sendNotification({ - templateId, - emailAddress, - personalisation: { - subject, - body - } - }) - - request.logger.info(logTags, 'Email sent successfully') - } catch (err) { - const errMsg = getErrorMessage(err) - request.logger.error( - errMsg, - `[emailSendFailed] Error sending notification email - templateId: ${templateId} - recipient: ${emailAddress} - ${errMsg}` - ) - - throw err - } -} diff --git a/src/server/utils/notify.test.ts b/src/server/utils/notify.test.ts deleted file mode 100644 index 72f661e7d..000000000 --- a/src/server/utils/notify.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { postJson } from '~/src/server/services/httpService.js' -import { sendNotification } from '~/src/server/utils/notify.js' - -jest.mock('~/src/server/services/httpService') - -describe('Utils: Notify', () => { - const templateId = 'example-template-id' - const emailAddress = 'enrique.chase@defra.gov.uk' - const personalisation = { - subject: 'Hello', - body: 'World' - } - - describe('sendNotification', () => { - it('calls postJson with personalised email payload', async () => { - await sendNotification({ - templateId, - emailAddress, - personalisation - }) - - expect(postJson).toHaveBeenCalledWith( - 'https://api.notifications.service.gov.uk/v2/notifications/email', - { - payload: { - template_id: templateId, - email_address: emailAddress, - personalisation - }, - headers: { - Authorization: expect.stringMatching(/^Bearer /) - } - } - ) - }) - }) -}) diff --git a/src/server/utils/notify.ts b/src/server/utils/notify.ts deleted file mode 100644 index feab44103..000000000 --- a/src/server/utils/notify.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { token } from '@hapi/jwt' - -import { config } from '~/src/config/index.js' -import { postJson } from '~/src/server/services/httpService.js' - -const notifyAPIKey = config.get('notifyAPIKey') - -// Extract the two uuids from the notifyApiKey -// See https://github.com/alphagov/notifications-node-client/blob/main/client/api_client.js#L17 -// Needed until `https://github.com/alphagov/notifications-node-client/pull/200` is published -const apiKeyId: string = notifyAPIKey.substring( - notifyAPIKey.length - 36, - notifyAPIKey.length -) -const serviceId: string = notifyAPIKey.substring( - notifyAPIKey.length - 73, - notifyAPIKey.length - 37 -) - -export interface SendNotificationArgs { - templateId: string - emailAddress: string - personalisation: { subject: string; body: string } -} - -function createToken(iss: string, secret: string) { - const iat = Math.round(Date.now() / 1000) - - return token.generate({ iss, iat }, secret, { - header: { typ: 'JWT', alg: 'HS256' } - }) -} - -export async function sendNotification(args: SendNotificationArgs) { - const { templateId, emailAddress, personalisation } = args - - return postJson( - 'https://api.notifications.service.gov.uk/v2/notifications/email', - { - payload: { - template_id: templateId, - email_address: emailAddress, - personalisation - }, - headers: { - Authorization: 'Bearer ' + createToken(serviceId, apiKeyId) - } - } - ) -} From d7f4b93454c44a90be37e88ea78a44e9cb1d4b1f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 21 Aug 2025 18:40:59 +0100 Subject: [PATCH 03/12] revert: restore notify service --- src/config/index.ts | 15 ++ src/server/plugins/engine/services/index.js | 33 +--- .../engine/services/notifyService.test.ts | 155 ++++++++++++++++++ .../plugins/engine/services/notifyService.ts | 78 +++++++++ src/server/utils/notify.ts | 50 ++++++ 5 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 src/server/plugins/engine/services/notifyService.test.ts create mode 100644 src/server/plugins/engine/services/notifyService.ts create mode 100644 src/server/utils/notify.ts diff --git a/src/config/index.ts b/src/config/index.ts index f933b79ce..e6300f769 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -161,6 +161,21 @@ export const config = convict({ } as SchemaObj }, + /** + * Email outputs + * Email outputs will use notify to send an email to a single inbox. + */ + notifyTemplateId: { + format: String, + default: '', + env: 'NOTIFY_TEMPLATE_ID' + } as SchemaObj, + notifyAPIKey: { + format: String, + default: '', + env: 'NOTIFY_API_KEY' + } as SchemaObj, + /** * API integrations */ diff --git a/src/server/plugins/engine/services/index.js b/src/server/plugins/engine/services/index.js index 833c79336..b990525f2 100644 --- a/src/server/plugins/engine/services/index.js +++ b/src/server/plugins/engine/services/index.js @@ -1,34 +1,3 @@ export * as formsService from '~/src/server/plugins/engine/services/formsService.js' export * as formSubmissionService from '~/src/server/plugins/engine/services/formSubmissionService.js' - -export const outputService = { - /** - * @param {FormContext} _context - * @param {FormRequestPayload} _request - * @param {FormModel} _model - * @param {string} _emailAddress - * @param {DetailItem[]} _items - * @param {SubmitResponsePayload} _submitResponse - * @returns {Promise} - */ - async submit( - _context, - _request, - _model, - _emailAddress, - _items, - _submitResponse - ) { - // No-op: Notification functionality has been moved to another service - // TODO: come back to this - return Promise.resolve() - } -} - -/** - * @import { FormContext } from '~/src/server/plugins/engine/types.js' - * @import { FormRequestPayload } from '~/src/server/routes/types.js' - * @import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' - * @import { DetailItem } from '~/src/server/plugins/engine/models/types.js' - * @import { SubmitResponsePayload } from '@defra/forms-model' - */ +export * as outputService from '~/src/server/plugins/engine/services/notifyService.js' diff --git a/src/server/plugins/engine/services/notifyService.test.ts b/src/server/plugins/engine/services/notifyService.test.ts new file mode 100644 index 000000000..f382b3c08 --- /dev/null +++ b/src/server/plugins/engine/services/notifyService.test.ts @@ -0,0 +1,155 @@ +import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js' +import { type FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' +import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js' +import { submit } from '~/src/server/plugins/engine/services/notifyService.js' +import { type FormContext } from '~/src/server/plugins/engine/types.js' +import { + FormStatus, + type FormRequestPayload +} from '~/src/server/routes/types.js' +import { sendNotification } from '~/src/server/utils/notify.js' + +jest.mock('~/src/server/utils/notify') +jest.mock('~/src/server/plugins/engine/helpers') +jest.mock('~/src/server/plugins/engine/outputFormatters/index') + +describe('notifyService', () => { + const submitResponse = { + message: 'Submit completed', + result: { + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: { + pizza: '11111111-1111-1111-1111-111111111111' + } + } + } + } + + const items: DetailItem[] = [] + + const mockRequest: FormRequestPayload = jest.mocked({ + path: 'test', + logger: { + info: jest.fn() + } + } as unknown as FormRequestPayload) + let model: FormModel + const sendNotificationMock = jest.mocked(sendNotification) + const formContext = {} as FormContext + + beforeEach(() => { + jest.resetAllMocks() + }) + + it('creates a subject line for real forms', async () => { + model = { + name: 'foobar', + def: { + output: { + audience: 'human', + version: '1' + } + } + } as FormModel + + jest.mocked(checkFormStatus).mockReturnValue({ + isPreview: false, + state: FormStatus.Draft + }) + jest.mocked(getFormatter).mockReturnValue(() => 'dummy-live') + + await submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse + ) + + expect(sendNotificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + personalisation: { + subject: `Form submission: foobar`, + body: 'dummy-live' + } + }) + ) + }) + + it('creates a subject line for preview forms', async () => { + model = { + name: 'foobar', + def: { + output: { + audience: 'human', + version: '1' + } + } + } as FormModel + + jest.mocked(checkFormStatus).mockReturnValue({ + isPreview: true, + state: FormStatus.Draft + }) + jest.mocked(getFormatter).mockReturnValue(() => 'dummy-preview') + + await submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse + ) + + expect(sendNotificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + personalisation: { + subject: `TEST FORM SUBMISSION: foobar`, + body: 'dummy-preview' + } + }) + ) + }) + + it('base64 encodes form data when aimed at machines', async () => { + model = { + name: 'foobar', + def: { + output: { + audience: 'machine', + version: '1' + } + } + } as FormModel + + jest.mocked(checkFormStatus).mockReturnValue({ + isPreview: true, + state: FormStatus.Draft + }) + jest + .mocked(getFormatter) + .mockReturnValue(() => 'dummy-preview " Hello world \' !@/') + + await submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse + ) + + expect(sendNotificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + personalisation: { + subject: `TEST FORM SUBMISSION: foobar`, + body: 'ZHVtbXktcHJldmlldyAiIEhlbGxvIHdvcmxkICcgIUAv' + } + }) + ) + }) +}) diff --git a/src/server/plugins/engine/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts new file mode 100644 index 000000000..ac130719a --- /dev/null +++ b/src/server/plugins/engine/services/notifyService.ts @@ -0,0 +1,78 @@ +import { getErrorMessage, type SubmitResponsePayload } from '@defra/forms-model' + +import { config } from '~/src/config/index.js' +import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers.js' +import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js' +import { type FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' +import { getFormatter } from '~/src/server/plugins/engine/outputFormatters/index.js' +import { type FormContext } from '~/src/server/plugins/engine/types.js' +import { type FormRequestPayload } from '~/src/server/routes/types.js' +import { sendNotification } from '~/src/server/utils/notify.js' + +const templateId = config.get('notifyTemplateId') + +/** + * Optional GOV.UK Notify service for consumers who want email notifications + * Can be disabled by not providing notifyTemplateId in config + * Can be overridden by providing a custom outputService in the services config + */ +export async function submit( + context: FormContext, + request: FormRequestPayload, + model: FormModel, + emailAddress: string, + items: DetailItem[], + submitResponse: SubmitResponsePayload +) { + if (!templateId) { + return Promise.resolve() + } + + const logTags = ['submit', 'email'] + const formStatus = checkFormStatus(request.params) + + // Get submission email personalisation + request.logger.info(logTags, 'Getting personalisation data') + + const formName = escapeMarkdown(model.name) + const subject = formStatus.isPreview + ? `TEST FORM SUBMISSION: ${formName}` + : `Form submission: ${formName}` + + const outputAudience = model.def.output?.audience ?? 'human' + const outputVersion = model.def.output?.version ?? '1' + + const outputFormatter = getFormatter(outputAudience, outputVersion) + let body = outputFormatter(context, items, model, submitResponse, formStatus) + + // GOV.UK Notify transforms quotes into curly quotes, so we can't just send the raw payload + // This is logic specific to Notify, so we include the logic here rather than in the formatter + if (outputAudience === 'machine') { + body = Buffer.from(body).toString('base64') + } + + request.logger.info(logTags, 'Sending email') + + try { + // Send submission email + await sendNotification({ + templateId, + emailAddress, + personalisation: { + subject, + body + } + }) + + request.logger.info(logTags, 'Email sent successfully') + } catch (err) { + const errMsg = getErrorMessage(err) + request.logger.error( + errMsg, + `[emailSendFailed] Error sending notification email - templateId: ${templateId} - recipient: ${emailAddress} - ${errMsg}` + ) + + throw err + } +} diff --git a/src/server/utils/notify.ts b/src/server/utils/notify.ts new file mode 100644 index 000000000..feab44103 --- /dev/null +++ b/src/server/utils/notify.ts @@ -0,0 +1,50 @@ +import { token } from '@hapi/jwt' + +import { config } from '~/src/config/index.js' +import { postJson } from '~/src/server/services/httpService.js' + +const notifyAPIKey = config.get('notifyAPIKey') + +// Extract the two uuids from the notifyApiKey +// See https://github.com/alphagov/notifications-node-client/blob/main/client/api_client.js#L17 +// Needed until `https://github.com/alphagov/notifications-node-client/pull/200` is published +const apiKeyId: string = notifyAPIKey.substring( + notifyAPIKey.length - 36, + notifyAPIKey.length +) +const serviceId: string = notifyAPIKey.substring( + notifyAPIKey.length - 73, + notifyAPIKey.length - 37 +) + +export interface SendNotificationArgs { + templateId: string + emailAddress: string + personalisation: { subject: string; body: string } +} + +function createToken(iss: string, secret: string) { + const iat = Math.round(Date.now() / 1000) + + return token.generate({ iss, iat }, secret, { + header: { typ: 'JWT', alg: 'HS256' } + }) +} + +export async function sendNotification(args: SendNotificationArgs) { + const { templateId, emailAddress, personalisation } = args + + return postJson( + 'https://api.notifications.service.gov.uk/v2/notifications/email', + { + payload: { + template_id: templateId, + email_address: emailAddress, + personalisation + }, + headers: { + Authorization: 'Bearer ' + createToken(serviceId, apiKeyId) + } + } + ) +} From 7289e09312901aaf0370d4e01e7cf344b5623b28 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 22 Aug 2025 12:23:12 +0100 Subject: [PATCH 04/12] revert: revert changes to v1 --- .../plugins/engine/outputFormatters/index.ts | 4 +- .../engine/outputFormatters/machine/v1.ts | 3 +- .../engine/outputFormatters/machine/v3.ts | 104 ++++++++++++++++++ src/server/plugins/engine/types.ts | 31 +++++- src/server/plugins/engine/types/index.ts | 8 ++ src/server/utils/notify.test.ts | 37 +++++++ 6 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/server/plugins/engine/outputFormatters/machine/v3.ts create mode 100644 src/server/utils/notify.test.ts diff --git a/src/server/plugins/engine/outputFormatters/index.ts b/src/server/plugins/engine/outputFormatters/index.ts index 6805cf104..9dadc5dd6 100644 --- a/src/server/plugins/engine/outputFormatters/index.ts +++ b/src/server/plugins/engine/outputFormatters/index.ts @@ -6,6 +6,7 @@ import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' import { format as formatHumanV1 } from '~/src/server/plugins/engine/outputFormatters/human/v1.js' import { format as formatMachineV1 } from '~/src/server/plugins/engine/outputFormatters/machine/v1.js' import { format as formatMachineV2 } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' +import { format as formatMachineV3 } from '~/src/server/plugins/engine/outputFormatters/machine/v3.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' type Formatter = ( @@ -25,7 +26,8 @@ const formatters: Record< }, machine: { '1': formatMachineV1, - '2': formatMachineV2 + '2': formatMachineV2, + '3': formatMachineV3 } } diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index 078cb1a93..e888d428f 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -30,8 +30,7 @@ export function format( schemaVersion: '1', timestamp: now.toISOString(), referenceNumber: context.referenceNumber, - formId: model.def.name, - formName: model.name + definition: model.def }, data: categorisedData } diff --git a/src/server/plugins/engine/outputFormatters/machine/v3.ts b/src/server/plugins/engine/outputFormatters/machine/v3.ts new file mode 100644 index 000000000..03887b961 --- /dev/null +++ b/src/server/plugins/engine/outputFormatters/machine/v3.ts @@ -0,0 +1,104 @@ +import { type SubmitResponsePayload } from '@defra/forms-model' + +import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' +import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' +import { format as machineV2 } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' +import { + FormAdapterSubmissionSchemaVersion, + type FormAdapterSubmissionMessageData, + type FormAdapterSubmissionMessagePayload, + type FormContext +} from '~/src/server/plugins/engine/types.js' +import { FormStatus } from '~/src/server/routes/types.js' + +export function format( + context: FormContext, + items: DetailItem[], + model: FormModel, + submitResponse: SubmitResponsePayload, + formStatus: ReturnType +): string { + const v2DataString = machineV2( + context, + items, + model, + submitResponse, + formStatus + ) + const v2DataParsed = JSON.parse(v2DataString) as { + data: FormAdapterSubmissionMessageData + } + + // Extract slug from basePath as form identifier + const formId = extractSlugFromBasePath(model.basePath) ?? '' + + const payload: FormAdapterSubmissionMessagePayload = { + meta: { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date(), + referenceNumber: context.referenceNumber, + formName: model.name, + formId, + formSlug: model.name, + status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, + isPreview: formStatus.isPreview, + notificationEmail: '' + }, + data: v2DataParsed.data + } + + return JSON.stringify(payload) +} + +export function extractSlugFromBasePath(basePath: string): string | null { + // basePath formats: + // - "slug" (live) + // - "preview/live/slug" or "preview/draft/slug" (preview) + const parts = basePath.split('/') + return parts[parts.length - 1] ?? null +} + +/** + * Creates a FormAdapterSubmissionMessagePayload with custom metadata + */ +export function createPayload( + context: FormContext, + items: DetailItem[], + model: FormModel, + submitResponse: SubmitResponsePayload, + formStatus: ReturnType, + metadata: { + formSlug?: string + notificationEmail?: string + } = {} +): FormAdapterSubmissionMessagePayload { + const v2DataString = machineV2( + context, + items, + model, + submitResponse, + formStatus + ) + const v2DataParsed = JSON.parse(v2DataString) as { + data: FormAdapterSubmissionMessageData + } + + // Extract slug from basePath as form identifier + const formId = extractSlugFromBasePath(model.basePath) ?? '' + + return { + meta: { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date(), + referenceNumber: context.referenceNumber, + formName: model.name, + formId, + formSlug: metadata.formSlug ?? '', + status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, + isPreview: formStatus.isPreview, + notificationEmail: metadata.notificationEmail ?? '' + }, + data: v2DataParsed.data + } +} diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3a566d6ff..943854208 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -18,6 +18,7 @@ import { type ComponentViewModel } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type RichFormValue } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js' import { type ViewContext } from '~/src/server/plugins/nunjucks/types.js' @@ -25,7 +26,8 @@ import { type FormAction, type FormParams, type FormRequest, - type FormRequestPayload + type FormRequestPayload, + type FormStatus } from '~/src/server/routes/types.js' import { type RequestOptions } from '~/src/server/services/httpService.js' import { type Services } from '~/src/server/types.js' @@ -384,3 +386,30 @@ export interface PluginOptions { onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" } + +export interface FormAdapterSubmissionMessagePayload { + meta: FormAdapterSubmissionMessageMeta + data: FormAdapterSubmissionMessageData +} + +export interface FormAdapterSubmissionMessageMeta { + schemaVersion: FormAdapterSubmissionSchemaVersion + timestamp: Date + referenceNumber: string + formName: string + formId: string + formSlug: string + status: FormStatus + isPreview: boolean + notificationEmail: string +} + +export interface FormAdapterSubmissionMessageData { + main: Record + repeaters: Record[]> + files: Record[]> +} + +export enum FormAdapterSubmissionSchemaVersion { + V1 = 1 +} diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index 297d0d2d0..d3e7760fc 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -82,3 +82,11 @@ export type { } from '~/src/server/types.js' export type { RichFormValue } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' + +export type { + FormAdapterSubmissionMessageData, + FormAdapterSubmissionMessageMeta, + FormAdapterSubmissionMessagePayload +} from '~/src/server/plugins/engine/types.js' + +export { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types.js' diff --git a/src/server/utils/notify.test.ts b/src/server/utils/notify.test.ts new file mode 100644 index 000000000..72f661e7d --- /dev/null +++ b/src/server/utils/notify.test.ts @@ -0,0 +1,37 @@ +import { postJson } from '~/src/server/services/httpService.js' +import { sendNotification } from '~/src/server/utils/notify.js' + +jest.mock('~/src/server/services/httpService') + +describe('Utils: Notify', () => { + const templateId = 'example-template-id' + const emailAddress = 'enrique.chase@defra.gov.uk' + const personalisation = { + subject: 'Hello', + body: 'World' + } + + describe('sendNotification', () => { + it('calls postJson with personalised email payload', async () => { + await sendNotification({ + templateId, + emailAddress, + personalisation + }) + + expect(postJson).toHaveBeenCalledWith( + 'https://api.notifications.service.gov.uk/v2/notifications/email', + { + payload: { + template_id: templateId, + email_address: emailAddress, + personalisation + }, + headers: { + Authorization: expect.stringMatching(/^Bearer /) + } + } + ) + }) + }) +}) From cf23a1892a7dbf06aeb3e7b09fa0017498a7258c Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 22 Aug 2025 15:53:48 +0100 Subject: [PATCH 05/12] refactor: grab form metadata --- .../plugins/engine/outputFormatters/index.ts | 8 +++-- .../engine/outputFormatters/machine/v3.ts | 31 +++++++------------ .../pageControllers/SummaryPageController.ts | 19 +++++++++--- .../plugins/engine/services/notifyService.ts | 18 +++++++++-- src/server/types.ts | 3 +- 5 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/index.ts b/src/server/plugins/engine/outputFormatters/index.ts index 9dadc5dd6..d48d933c4 100644 --- a/src/server/plugins/engine/outputFormatters/index.ts +++ b/src/server/plugins/engine/outputFormatters/index.ts @@ -1,4 +1,7 @@ -import { type SubmitResponsePayload } from '@defra/forms-model' +import { + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' @@ -14,7 +17,8 @@ type Formatter = ( items: DetailItem[], model: FormModel, submitResponse: SubmitResponsePayload, - formStatus: ReturnType + formStatus: ReturnType, + formMetadata?: FormMetadata ) => string const formatters: Record< diff --git a/src/server/plugins/engine/outputFormatters/machine/v3.ts b/src/server/plugins/engine/outputFormatters/machine/v3.ts index 03887b961..38fa778d0 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v3.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v3.ts @@ -1,4 +1,7 @@ -import { type SubmitResponsePayload } from '@defra/forms-model' +import { + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js' @@ -17,7 +20,8 @@ export function format( items: DetailItem[], model: FormModel, submitResponse: SubmitResponsePayload, - formStatus: ReturnType + formStatus: ReturnType, + formMetadata?: FormMetadata ): string { const v2DataString = machineV2( context, @@ -30,17 +34,14 @@ export function format( data: FormAdapterSubmissionMessageData } - // Extract slug from basePath as form identifier - const formId = extractSlugFromBasePath(model.basePath) ?? '' - const payload: FormAdapterSubmissionMessagePayload = { meta: { schemaVersion: FormAdapterSubmissionSchemaVersion.V1, timestamp: new Date(), referenceNumber: context.referenceNumber, formName: model.name, - formId, - formSlug: model.name, + formId: formMetadata?.id ?? '', + formSlug: formMetadata?.slug ?? '', status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, notificationEmail: '' @@ -51,14 +52,6 @@ export function format( return JSON.stringify(payload) } -export function extractSlugFromBasePath(basePath: string): string | null { - // basePath formats: - // - "slug" (live) - // - "preview/live/slug" or "preview/draft/slug" (preview) - const parts = basePath.split('/') - return parts[parts.length - 1] ?? null -} - /** * Creates a FormAdapterSubmissionMessagePayload with custom metadata */ @@ -68,6 +61,7 @@ export function createPayload( model: FormModel, submitResponse: SubmitResponsePayload, formStatus: ReturnType, + formMetadata?: FormMetadata, metadata: { formSlug?: string notificationEmail?: string @@ -84,17 +78,14 @@ export function createPayload( data: FormAdapterSubmissionMessageData } - // Extract slug from basePath as form identifier - const formId = extractSlugFromBasePath(model.basePath) ?? '' - return { meta: { schemaVersion: FormAdapterSubmissionSchemaVersion.V1, timestamp: new Date(), referenceNumber: context.referenceNumber, formName: model.name, - formId, - formSlug: metadata.formSlug ?? '', + formId: formMetadata?.id ?? '', + formSlug: metadata.formSlug ?? formMetadata?.slug ?? model.name, status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, notificationEmail: metadata.notificationEmail ?? '' diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 039063989..76e3d46fd 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -1,5 +1,6 @@ import { hasComponentsEvenIfNoNext, + type FormMetadata, type Page, type SubmitPayload } from '@defra/forms-model' @@ -111,7 +112,8 @@ export class SummaryPageController extends QuestionPageController { const { getFormMetadata } = formsService // Get the form metadata using the `slug` param - const { notificationEmail } = await getFormMetadata(params.slug) + const formMetadata = await getFormMetadata(params.slug) + const { notificationEmail } = formMetadata const { isPreview } = checkFormStatus(request.params) const emailAddress = notificationEmail ?? this.model.def.outputEmail @@ -120,7 +122,14 @@ export class SummaryPageController extends QuestionPageController { // Send submission email if (emailAddress) { const viewModel = this.getSummaryViewModel(request, context) - await submitForm(context, request, viewModel, model, emailAddress) + await submitForm( + context, + request, + viewModel, + model, + emailAddress, + formMetadata + ) } await cacheService.setConfirmationState(request, { confirmed: true }) @@ -150,7 +159,8 @@ async function submitForm( request: FormRequestPayload, summaryViewModel: SummaryViewModel, model: FormModel, - emailAddress: string + emailAddress: string, + formMetadata: FormMetadata ) { await extendFileRetention(model, context.state, emailAddress) @@ -184,7 +194,8 @@ async function submitForm( model, emailAddress, items, - submitResponse + submitResponse, + formMetadata ) } diff --git a/src/server/plugins/engine/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts index ac130719a..801c887b5 100644 --- a/src/server/plugins/engine/services/notifyService.ts +++ b/src/server/plugins/engine/services/notifyService.ts @@ -1,4 +1,8 @@ -import { getErrorMessage, type SubmitResponsePayload } from '@defra/forms-model' +import { + getErrorMessage, + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { config } from '~/src/config/index.js' import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers.js' @@ -23,7 +27,8 @@ export async function submit( model: FormModel, emailAddress: string, items: DetailItem[], - submitResponse: SubmitResponsePayload + submitResponse: SubmitResponsePayload, + formMetadata?: FormMetadata ) { if (!templateId) { return Promise.resolve() @@ -44,7 +49,14 @@ export async function submit( const outputVersion = model.def.output?.version ?? '1' const outputFormatter = getFormatter(outputAudience, outputVersion) - let body = outputFormatter(context, items, model, submitResponse, formStatus) + let body = outputFormatter( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) // GOV.UK Notify transforms quotes into curly quotes, so we can't just send the raw payload // This is logic specific to Notify, so we include the logic here rather than in the formatter diff --git a/src/server/types.ts b/src/server/types.ts index 92ddb9382..10f50cc3f 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -59,6 +59,7 @@ export interface OutputService { model: FormModel, emailAddress: string, items: DetailItem[], - submitResponse: SubmitResponsePayload + submitResponse: SubmitResponsePayload, + formMetadata?: FormMetadata ) => Promise } From 856661fd0b9f860be6a85278ab1bf595045f8743 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 22 Aug 2025 18:24:31 +0100 Subject: [PATCH 06/12] feat: finalise v3 formattor --- .../engine/outputFormatters/machine/v3.ts | 44 +------------------ 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/machine/v3.ts b/src/server/plugins/engine/outputFormatters/machine/v3.ts index 38fa778d0..9264de319 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v3.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v3.ts @@ -44,52 +44,10 @@ export function format( formSlug: formMetadata?.slug ?? '', status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, isPreview: formStatus.isPreview, - notificationEmail: '' + notificationEmail: formMetadata?.notificationEmail ?? '' }, data: v2DataParsed.data } return JSON.stringify(payload) } - -/** - * Creates a FormAdapterSubmissionMessagePayload with custom metadata - */ -export function createPayload( - context: FormContext, - items: DetailItem[], - model: FormModel, - submitResponse: SubmitResponsePayload, - formStatus: ReturnType, - formMetadata?: FormMetadata, - metadata: { - formSlug?: string - notificationEmail?: string - } = {} -): FormAdapterSubmissionMessagePayload { - const v2DataString = machineV2( - context, - items, - model, - submitResponse, - formStatus - ) - const v2DataParsed = JSON.parse(v2DataString) as { - data: FormAdapterSubmissionMessageData - } - - return { - meta: { - schemaVersion: FormAdapterSubmissionSchemaVersion.V1, - timestamp: new Date(), - referenceNumber: context.referenceNumber, - formName: model.name, - formId: formMetadata?.id ?? '', - formSlug: metadata.formSlug ?? formMetadata?.slug ?? model.name, - status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, - isPreview: formStatus.isPreview, - notificationEmail: metadata.notificationEmail ?? '' - }, - data: v2DataParsed.data - } -} From 61798ab821ed9d5692f9bd9c2dc4ee6b474c858f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 26 Aug 2025 12:17:57 +0100 Subject: [PATCH 07/12] refactor: adapter as property in formatters --- src/server/plugins/engine/outputFormatters/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/index.ts b/src/server/plugins/engine/outputFormatters/index.ts index d48d933c4..efcedb24e 100644 --- a/src/server/plugins/engine/outputFormatters/index.ts +++ b/src/server/plugins/engine/outputFormatters/index.ts @@ -23,15 +23,17 @@ type Formatter = ( const formatters: Record< string, - Record | undefined + Record | undefined > = { human: { '1': formatHumanV1 }, machine: { '1': formatMachineV1, - '2': formatMachineV2, - '3': formatMachineV3 + '2': formatMachineV2 + }, + adapter: { + '1': formatMachineV3 } } From ce5fa477a9326a41b4acf339633d38e214ce655e Mon Sep 17 00:00:00 2001 From: whitewater design Date: Tue, 26 Aug 2025 15:34:12 +0100 Subject: [PATCH 08/12] build: DF-388 - Update FormAdapterSubmissionMessagePayload types --- src/server/plugins/engine/types.ts | 31 +++++++++++++--- src/server/plugins/engine/types/index.ts | 20 ++++++---- src/server/plugins/engine/types/schema.ts | 45 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 src/server/plugins/engine/types/schema.ts diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 943854208..dbb5087d9 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -387,11 +387,6 @@ export interface PluginOptions { baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" } -export interface FormAdapterSubmissionMessagePayload { - meta: FormAdapterSubmissionMessageMeta - data: FormAdapterSubmissionMessageData -} - export interface FormAdapterSubmissionMessageMeta { schemaVersion: FormAdapterSubmissionSchemaVersion timestamp: Date @@ -404,6 +399,15 @@ export interface FormAdapterSubmissionMessageMeta { notificationEmail: string } +export type FormAdapterSubmissionMessageMetaSerialised = Omit< + FormAdapterSubmissionMessageMeta, + 'schemaVersion' | 'timestamp' | 'status' +> & { + schemaVersion: string + status: string + timestamp: string +} + export interface FormAdapterSubmissionMessageData { main: Record repeaters: Record[]> @@ -413,3 +417,20 @@ export interface FormAdapterSubmissionMessageData { export enum FormAdapterSubmissionSchemaVersion { V1 = 1 } + +export interface FormAdapterSubmissionMessagePayload { + meta: FormAdapterSubmissionMessageMeta + data: FormAdapterSubmissionMessageData +} + +export interface FormAdapterSubmissionMessage + extends FormAdapterSubmissionMessagePayload { + messageId: string + recordCreatedAt: Date +} + +export interface FormAdapterSubmissionService { + handleFormSubmission: ( + submissionMessage: FormAdapterSubmissionMessage + ) => unknown +} diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index d3e7760fc..c00c524e0 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -5,6 +5,12 @@ export type { FeaturedFormPageViewModel, FileState, FilterFunction, + FormAdapterSubmissionMessage, + FormAdapterSubmissionMessageData, + FormAdapterSubmissionMessageMeta, + FormAdapterSubmissionMessageMetaSerialised, + FormAdapterSubmissionMessagePayload, + FormAdapterSubmissionService, FormContext, FormContextRequest, FormPageViewModel, @@ -35,7 +41,11 @@ export type { UploadStatusResponse } from '~/src/server/plugins/engine/types.js' -export { FileStatus, UploadStatus } from '~/src/server/plugins/engine/types.js' +export { + FileStatus, + FormAdapterSubmissionSchemaVersion, + UploadStatus +} from '~/src/server/plugins/engine/types.js' export type { Detail, @@ -83,10 +93,4 @@ export type { export type { RichFormValue } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' -export type { - FormAdapterSubmissionMessageData, - FormAdapterSubmissionMessageMeta, - FormAdapterSubmissionMessagePayload -} from '~/src/server/plugins/engine/types.js' - -export { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types.js' +export * from '~/src/server/plugins/engine/types/schema.js' diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts new file mode 100644 index 000000000..cc21d480b --- /dev/null +++ b/src/server/plugins/engine/types/schema.ts @@ -0,0 +1,45 @@ +import { + FormStatus, + idSchema, + notificationEmailAddressSchema, + slugSchema, + titleSchema +} from '@defra/forms-model' +import Joi from 'joi' + +import { + FormAdapterSubmissionSchemaVersion, + type FormAdapterSubmissionMessageData, + type FormAdapterSubmissionMessageMeta, + type FormAdapterSubmissionMessagePayload +} from '~/src/server/plugins/engine/types.js' + +export const formAdapterSubmissionMessageMetaSchema = + Joi.object().keys({ + schemaVersion: Joi.string().valid( + ...Object.values(FormAdapterSubmissionSchemaVersion) + ), + timestamp: Joi.date().required(), + referenceNumber: Joi.string().required(), + formName: titleSchema, + formId: idSchema, + formSlug: slugSchema, + status: Joi.string() + .valid(...Object.values(FormStatus)) + .required(), + isPreview: Joi.boolean().required(), + notificationEmail: notificationEmailAddressSchema.required() + }) + +export const formAdapterSubmissionMessageDataSchema = + Joi.object().keys({ + main: Joi.object(), + repeaters: Joi.object(), + files: Joi.object() + }) + +export const formAdapterSubmissionMessagePayloadSchema = + Joi.object().keys({ + meta: formAdapterSubmissionMessageMetaSchema.required(), + data: formAdapterSubmissionMessageDataSchema.required() + }) From 383900c5becdd132eedd002fe987e2bd8c8dd55f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 26 Aug 2025 16:08:04 +0100 Subject: [PATCH 09/12] refactor: new subdir for adapter formatter --- .../outputFormatters/{machine/v3.ts => adapter/v1.ts} | 0 src/server/plugins/engine/outputFormatters/index.ts | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/server/plugins/engine/outputFormatters/{machine/v3.ts => adapter/v1.ts} (100%) diff --git a/src/server/plugins/engine/outputFormatters/machine/v3.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts similarity index 100% rename from src/server/plugins/engine/outputFormatters/machine/v3.ts rename to src/server/plugins/engine/outputFormatters/adapter/v1.ts diff --git a/src/server/plugins/engine/outputFormatters/index.ts b/src/server/plugins/engine/outputFormatters/index.ts index efcedb24e..4356dea02 100644 --- a/src/server/plugins/engine/outputFormatters/index.ts +++ b/src/server/plugins/engine/outputFormatters/index.ts @@ -6,10 +6,10 @@ import { import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' +import { format as formatAdapterV1 } from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js' import { format as formatHumanV1 } from '~/src/server/plugins/engine/outputFormatters/human/v1.js' import { format as formatMachineV1 } from '~/src/server/plugins/engine/outputFormatters/machine/v1.js' import { format as formatMachineV2 } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js' -import { format as formatMachineV3 } from '~/src/server/plugins/engine/outputFormatters/machine/v3.js' import { type FormContext } from '~/src/server/plugins/engine/types.js' type Formatter = ( @@ -23,7 +23,7 @@ type Formatter = ( const formatters: Record< string, - Record | undefined + Record | undefined > = { human: { '1': formatHumanV1 @@ -33,7 +33,7 @@ const formatters: Record< '2': formatMachineV2 }, adapter: { - '1': formatMachineV3 + '1': formatAdapterV1 } } From 1a16ba1bfea2a3a15220092e45e454df0436c161 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 27 Aug 2025 09:32:32 +0100 Subject: [PATCH 10/12] test: tests for v1 and notifyService --- .../outputFormatters/adapter/v1.test.ts | 506 ++++++++++++++++++ .../engine/services/notifyService.test.ts | 117 +++- 2 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 src/server/plugins/engine/outputFormatters/adapter/v1.test.ts diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts new file mode 100644 index 000000000..f34609cac --- /dev/null +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -0,0 +1,506 @@ +import { type FormMetadata } from '@defra/forms-model' + +import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' +import { type Field } from '~/src/server/plugins/engine/components/helpers.js' +import { FormModel } from '~/src/server/plugins/engine/models/index.js' +import { + type DetailItem, + type DetailItemField, + type DetailItemRepeat +} from '~/src/server/plugins/engine/models/types.js' +import { format } from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js' +import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { + FileStatus, + FormAdapterSubmissionSchemaVersion, + UploadStatus, + type FileState, + type FormAdapterSubmissionMessagePayload +} from '~/src/server/plugins/engine/types.js' +import { FormStatus } from '~/src/server/routes/types.js' +import definition from '~/test/form/definitions/repeat-mixed.js' + +const submitResponse = { + message: 'Submit completed', + result: { + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: { + pizza: '11111111-1111-1111-1111-111111111111' + } + } + } +} + +const model = new FormModel(definition, { + basePath: 'test' +}) + +const dummyField: Field = { + getFormValueFromState: (_) => 'hello world' +} as Field + +const itemId1 = 'abc-123' +const itemId2 = 'xyz-987' + +const state = { + $$__referenceNumber: 'foobar', + orderType: 'delivery', + pizza: [ + { + toppings: 'Ham', + quantity: 2, + itemId: itemId1 + }, + { + toppings: 'Pepperoni', + quantity: 1, + itemId: itemId2 + } + ] +} + +const pageUrl = new URL('http://example.com/repeat/pizza-order/summary') + +const request = buildFormContextRequest({ + method: 'get', + url: pageUrl, + path: pageUrl.pathname, + params: { + path: 'pizza-order', + slug: 'repeat' + }, + query: {}, + app: { model } +}) + +const context = model.getFormContext(request, state) + +const testDetailItemField: DetailItemField = { + name: 'exampleField', + label: 'Example Field', + href: '/example-field', + title: 'Example Field Title', + field: dummyField, + value: 'Example Value' +} as DetailItemField + +const testDetailItemField2: DetailItemField = { + name: 'exampleField2', + label: 'Example Field 2', + href: '/example-field-2', + title: 'Example Field 2 Title', + field: dummyField, + value: 'Example Value 2' +} as DetailItemField + +const testDetailItemRepeat: DetailItemRepeat = { + name: 'exampleRepeat', + label: 'Example Repeat', + href: '/example-repeat', + title: 'Example Repeat Title', + value: 'Example Repeat Value', + subItems: [ + [ + { + name: 'subItem1_1', + label: 'Sub Item 1 1', + field: dummyField, + href: '/sub-item-1-1', + title: 'Sub Item 1 1 Title', + value: 'Sub Item 1 1 Value' + } as DetailItemField, + { + name: 'subItem1_2', + label: 'Sub Item 1 2', + field: dummyField, + href: '/sub-item-1-2', + title: 'Sub Item 1 2 Title', + value: 'Sub Item 1 2 Value' + } as DetailItemField + ], + [ + { + name: 'subItem2_1', + label: 'Sub Item 2 1', + field: dummyField, + href: '/sub-item-2-1', + title: 'Sub Item 2 1 Title', + value: 'Sub Item 2 1 Value' + } as DetailItemField + ] + ] +} as DetailItemRepeat + +const fileState: FileState = { + uploadId: '123', + status: { + form: { + file: { + fileId: '123-456-789', + contentLength: 1, + filename: 'foobar.txt', + fileStatus: FileStatus.complete + } + }, + uploadStatus: UploadStatus.ready, + numberOfRejectedFiles: 0, + metadata: { + retrievalKey: '123' + } + } +} + +const fileState2: FileState = { + uploadId: '456', + status: { + form: { + file: { + fileId: '456-789-123', + contentLength: 1, + filename: 'bazbuzz.txt', + fileStatus: FileStatus.complete + } + }, + uploadStatus: UploadStatus.ready, + numberOfRejectedFiles: 0, + metadata: { + retrievalKey: '456' + } + } +} + +const testDetailItemFile1: DetailItemField = Object.create( + FileUploadField.prototype +) +Object.assign(testDetailItemFile1, { + name: 'exampleFile1', + label: 'Example File Field', + href: '/example-file', + title: 'Example File Field Title', + field: testDetailItemFile1, + value: 'Example File Value', + state: { + exampleFile1: [fileState, fileState2] + } +}) + +const items: DetailItem[] = [ + testDetailItemField, + testDetailItemField2, + testDetailItemRepeat, + testDetailItemFile1 +] + +describe('Adapter v1 formatter', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-15T10:30:00.000Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return the adapter v1 output with complete formMetadata', () => { + const formMetadata: FormMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta).toEqual({ + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: '2024-01-15T10:30:00.000Z', + referenceNumber: 'foobar', + formName: definition.name, + formId: 'form-123', + formSlug: 'test-form', + status: FormStatus.Live, + isPreview: false, + notificationEmail: 'test@example.com' + }) + + expect(parsedBody.data).toEqual({ + main: { + exampleField: 'hello world', + exampleField2: 'hello world' + }, + repeaters: { + exampleRepeat: [ + { + subItem1_1: 'hello world', + subItem1_2: 'hello world' + }, + { + subItem2_1: 'hello world' + } + ] + }, + files: { + exampleFile1: [ + { + fileId: '123-456-789', + userDownloadLink: 'https://forms-designer/file-download/123-456-789' + }, + { + fileId: '456-789-123', + userDownloadLink: 'https://forms-designer/file-download/456-789-123' + } + ] + } + }) + }) + + it('should handle preview form status correctly', () => { + const formMetadata: FormMetadata = { + id: 'form-123', + slug: 'test-form', + title: 'Test Form', + notificationEmail: 'test@example.com' + } as FormMetadata + + const formStatus = { + isPreview: true, + state: FormStatus.Draft + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.status).toBe(FormStatus.Draft) + expect(parsedBody.meta.isPreview).toBe(true) + }) + + it('should handle missing formMetadata with empty strings', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.formId).toBe('') + expect(parsedBody.meta.formSlug).toBe('') + expect(parsedBody.meta.notificationEmail).toBe('') + expect(parsedBody.meta.formName).toBe(definition.name) + expect(parsedBody.meta.referenceNumber).toBe('foobar') + }) + + it('should handle partial formMetadata', () => { + const formMetadata: FormMetadata = { + id: 'form-456', + slug: 'partial-form', + title: 'Partial Form' + } as FormMetadata + + const formStatus = { + isPreview: true, + state: FormStatus.Draft + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.formId).toBe('form-456') + expect(parsedBody.meta.formSlug).toBe('partial-form') + expect(parsedBody.meta.notificationEmail).toBe('') + expect(parsedBody.meta.status).toBe(FormStatus.Draft) + expect(parsedBody.meta.isPreview).toBe(true) + }) + + it('should use correct schema version', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.schemaVersion).toBe( + FormAdapterSubmissionSchemaVersion.V1 + ) + expect(parsedBody.meta.schemaVersion).toBe(1) + }) + + it('should generate valid timestamp', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.timestamp).toBe('2024-01-15T10:30:00.000Z') + expect(typeof parsedBody.meta.timestamp).toBe('string') + + expect(new Date(parsedBody.meta.timestamp)).toEqual( + new Date('2024-01-15T10:30:00.000Z') + ) + }) + + it('should handle empty items array', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, [], model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.data.main).toEqual({}) + expect(parsedBody.data.repeaters).toEqual({}) + expect(parsedBody.data.files).toEqual({}) + }) + + it('should handle different form statuses', () => { + const testCases = [ + { + isPreview: false, + state: FormStatus.Live, + expectedStatus: FormStatus.Live + }, + { + isPreview: true, + state: FormStatus.Draft, + expectedStatus: FormStatus.Draft + }, + { + isPreview: true, + state: FormStatus.Live, + expectedStatus: FormStatus.Draft + } + ] + + testCases.forEach(({ isPreview, state, expectedStatus }) => { + const formStatus = { isPreview, state } + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.status).toBe(expectedStatus) + expect(parsedBody.meta.isPreview).toBe(isPreview) + }) + }) + + it('should return valid JSON string', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, items, model, submitResponse, formStatus) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + expect(() => JSON.parse(body)).not.toThrow() + expect(typeof body).toBe('string') + }) + + it('should handle formMetadata with only id', () => { + const formMetadata: FormMetadata = { + id: 'only-id-form' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.formId).toBe('only-id-form') + expect(parsedBody.meta.formSlug).toBe('') + expect(parsedBody.meta.notificationEmail).toBe('') + }) + + it('should handle formMetadata with only slug', () => { + const formMetadata: FormMetadata = { + slug: 'only-slug-form' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.formId).toBe('') + expect(parsedBody.meta.formSlug).toBe('only-slug-form') + expect(parsedBody.meta.notificationEmail).toBe('') + }) + + it('should handle formMetadata with only notificationEmail', () => { + const formMetadata: FormMetadata = { + notificationEmail: 'only-email@example.com' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.meta.formId).toBe('') + expect(parsedBody.meta.formSlug).toBe('') + expect(parsedBody.meta.notificationEmail).toBe('only-email@example.com') + }) +}) diff --git a/src/server/plugins/engine/services/notifyService.test.ts b/src/server/plugins/engine/services/notifyService.test.ts index f382b3c08..9955e88fa 100644 --- a/src/server/plugins/engine/services/notifyService.test.ts +++ b/src/server/plugins/engine/services/notifyService.test.ts @@ -1,3 +1,6 @@ +import { type FormMetadata } from '@defra/forms-model' + +import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers.js' import { checkFormStatus } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' @@ -13,6 +16,7 @@ import { sendNotification } from '~/src/server/utils/notify.js' jest.mock('~/src/server/utils/notify') jest.mock('~/src/server/plugins/engine/helpers') jest.mock('~/src/server/plugins/engine/outputFormatters/index') +jest.mock('~/src/server/plugins/engine/components/helpers') describe('notifyService', () => { const submitResponse = { @@ -32,7 +36,8 @@ describe('notifyService', () => { const mockRequest: FormRequestPayload = jest.mocked({ path: 'test', logger: { - info: jest.fn() + info: jest.fn(), + error: jest.fn() } } as unknown as FormRequestPayload) let model: FormModel @@ -41,6 +46,7 @@ describe('notifyService', () => { beforeEach(() => { jest.resetAllMocks() + jest.mocked(escapeMarkdown).mockImplementation((text) => text) }) it('creates a subject line for real forms', async () => { @@ -152,4 +158,113 @@ describe('notifyService', () => { }) ) }) + + it('calls outputFormatter with all correct arguments', async () => { + const mockFormatter = jest.fn().mockReturnValue('formatted-output') + jest.mocked(getFormatter).mockReturnValue(mockFormatter) + + const formMetadata: FormMetadata = { + id: 'form-id', + slug: 'form-slug', + title: 'Form Title' + } as FormMetadata + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + jest.mocked(checkFormStatus).mockReturnValue(formStatus) + + model = { + name: 'foobar', + def: { + output: { + audience: 'human', + version: '1' + } + } + } as FormModel + + await submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse, + formMetadata + ) + + expect(getFormatter).toHaveBeenCalledWith('human', '1') + + expect(mockFormatter).toHaveBeenCalledWith( + formContext, + items, + model, + submitResponse, + formStatus, + formMetadata + ) + + expect(sendNotificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + personalisation: { + subject: 'Form submission: foobar', + body: 'formatted-output' + } + }) + ) + }) + + it('calls outputFormatter without formMetadata when not provided', async () => { + const mockFormatter = jest.fn().mockReturnValue('formatted-output') + jest.mocked(getFormatter).mockReturnValue(mockFormatter) + + const formStatus = { + isPreview: true, + state: FormStatus.Draft + } + + jest.mocked(checkFormStatus).mockReturnValue(formStatus) + + model = { + name: 'foobar', + def: { + output: { + audience: 'machine', + version: '2' + } + } + } as FormModel + + await submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse + ) + + expect(getFormatter).toHaveBeenCalledWith('machine', '2') + + expect(mockFormatter).toHaveBeenCalledWith( + formContext, + items, + model, + submitResponse, + formStatus, + undefined + ) + + expect(sendNotificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + personalisation: { + subject: 'TEST FORM SUBMISSION: foobar', + body: Buffer.from('formatted-output').toString('base64') + } + }) + ) + }) }) From 60183d635d399c6a4883fbb9a50f89d55f6e4ac7 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 27 Aug 2025 11:25:09 +0100 Subject: [PATCH 11/12] test: added schema tests and increase coverage of notifyService --- .../engine/services/notifyService.test.ts | 40 +++++ .../plugins/engine/types/schema.test.ts | 152 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/server/plugins/engine/types/schema.test.ts diff --git a/src/server/plugins/engine/services/notifyService.test.ts b/src/server/plugins/engine/services/notifyService.test.ts index 9955e88fa..720ce6d4d 100644 --- a/src/server/plugins/engine/services/notifyService.test.ts +++ b/src/server/plugins/engine/services/notifyService.test.ts @@ -267,4 +267,44 @@ describe('notifyService', () => { }) ) }) + + it('should handle sendNotification errors and rethrow', async () => { + const mockFormatter = jest.fn().mockReturnValue('formatted-output') + jest.mocked(getFormatter).mockReturnValue(mockFormatter) + jest.mocked(checkFormStatus).mockReturnValue({ + isPreview: false, + state: FormStatus.Live + }) + + const testError = new Error('Notification service unavailable') + sendNotificationMock.mockRejectedValue(testError) + + model = { + name: 'foobar', + def: { + output: { + audience: 'human', + version: '1' + } + } + } as FormModel + + await expect( + submit( + formContext, + mockRequest, + model, + 'test@defra.gov.uk', + items, + submitResponse + ) + ).rejects.toThrow('Notification service unavailable') + + expect(mockRequest.logger.error).toHaveBeenCalledWith( + 'Notification service unavailable', + expect.stringContaining( + '[emailSendFailed] Error sending notification email' + ) + ) + }) }) diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts new file mode 100644 index 000000000..bc7605cb9 --- /dev/null +++ b/src/server/plugins/engine/types/schema.test.ts @@ -0,0 +1,152 @@ +import { FormStatus } from '@defra/forms-model' + +import { + formAdapterSubmissionMessageDataSchema, + formAdapterSubmissionMessageMetaSchema, + formAdapterSubmissionMessagePayloadSchema +} from '~/src/server/plugins/engine/types/schema.js' +import { + FormAdapterSubmissionSchemaVersion, + type FormAdapterSubmissionMessageData, + type FormAdapterSubmissionMessageMeta, + type FormAdapterSubmissionMessagePayload +} from '~/src/server/plugins/engine/types.js' + +describe('Schema validation', () => { + describe('formAdapterSubmissionMessageMetaSchema', () => { + const validMeta: FormAdapterSubmissionMessageMeta = { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date('2025-08-22T18:15:10.785Z'), + referenceNumber: '576-225-943', + formName: 'Order a pizza', + formId: '68a8b0449ab460290c28940a', + formSlug: 'order-a-pizza', + status: FormStatus.Live, + isPreview: false, + notificationEmail: 'info@example.com' + } + + it('should validate valid meta object', () => { + const { error } = + formAdapterSubmissionMessageMetaSchema.validate(validMeta) + expect(error).toBeUndefined() + }) + + it('should reject invalid schema version', () => { + const invalidMeta = { ...validMeta, schemaVersion: 'invalid' } + const { error } = + formAdapterSubmissionMessageMetaSchema.validate(invalidMeta) + expect(error).toBeDefined() + }) + + it('should reject missing required fields', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { timestamp: _, ...metaWithoutTimestamp } = validMeta + const { error } = + formAdapterSubmissionMessageMetaSchema.validate(metaWithoutTimestamp) + expect(error).toBeDefined() + }) + }) + + describe('formAdapterSubmissionMessageDataSchema', () => { + const validData: FormAdapterSubmissionMessageData = { + main: { + QMwMir: 'Roman Pizza', + duOEvZ: 'Small', + DzEODf: ['Mozzarella'], + juiCfC: ['Pepperoni', 'Sausage', 'Onions', 'Basil'], + YEpypP: 'None', + JumNVc: 'Joe Bloggs', + ALNehP: '+441234567890', + vAqTmg: { + addressLine1: '1 Anywhere Street', + town: 'Anywhereville', + postcode: 'AN1 2WH' + }, + IbXVGY: { + day: 22, + month: 8, + year: 2025 + }, + HGBWLt: ['Garlic sauce'] + }, + repeaters: {}, + files: {} + } + + it('should validate valid data object', () => { + const { error } = + formAdapterSubmissionMessageDataSchema.validate(validData) + expect(error).toBeUndefined() + }) + + it('should validate empty objects', () => { + const emptyData = { main: {}, repeaters: {}, files: {} } + const { error } = + formAdapterSubmissionMessageDataSchema.validate(emptyData) + expect(error).toBeUndefined() + }) + }) + + describe('formAdapterSubmissionMessagePayloadSchema', () => { + const validPayload: FormAdapterSubmissionMessagePayload = { + meta: { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date('2025-08-22T18:15:10.785Z'), + referenceNumber: '576-225-943', + formName: 'Order a pizza', + formId: '68a8b0449ab460290c28940a', + formSlug: 'order-a-pizza', + status: FormStatus.Live, + isPreview: false, + notificationEmail: 'info@example.com' + }, + data: { + main: { + QMwMir: 'Roman Pizza', + duOEvZ: 'Small', + DzEODf: ['Mozzarella'], + juiCfC: ['Pepperoni', 'Sausage', 'Onions', 'Basil'], + YEpypP: 'None', + JumNVc: 'Joe Bloggs', + ALNehP: '+441234567890', + vAqTmg: { + addressLine1: '1 Anywhere Street', + town: 'Anywhereville', + postcode: 'AN1 2WH' + }, + IbXVGY: { + day: 22, + month: 8, + year: 2025 + }, + HGBWLt: ['Garlic sauce'] + }, + repeaters: {}, + files: {} + } + } + + it('should validate complete payload', () => { + const { error } = + formAdapterSubmissionMessagePayloadSchema.validate(validPayload) + expect(error).toBeUndefined() + }) + + it('should reject payload without meta', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta: _, ...payloadWithoutMeta } = validPayload + const { error } = + formAdapterSubmissionMessagePayloadSchema.validate(payloadWithoutMeta) + expect(error).toBeDefined() + }) + + it('should reject payload without data', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { data: _, ...payloadWithoutData } = validPayload + const { error } = + formAdapterSubmissionMessagePayloadSchema.validate(payloadWithoutData) + expect(error).toBeDefined() + }) + }) +}) From 3be833af010e17fc7b1fca206d1d6f53b1897cfb Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 27 Aug 2025 12:27:08 +0100 Subject: [PATCH 12/12] chore: add _formMetadata to v1 --- src/server/plugins/engine/outputFormatters/human/v1.ts | 8 ++++++-- src/server/plugins/engine/outputFormatters/machine/v1.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index 7f1226c8d..bd700a930 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -1,4 +1,7 @@ -import { type SubmitResponsePayload } from '@defra/forms-model' +import { + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { addDays, format as dateFormat } from 'date-fns' import { config } from '~/src/config/index.js' @@ -18,7 +21,8 @@ export function format( items: DetailItem[], model: FormModel, submitResponse: SubmitResponsePayload, - formStatus: ReturnType + formStatus: ReturnType, + _formMetadata?: FormMetadata ) { const { files } = submitResponse.result diff --git a/src/server/plugins/engine/outputFormatters/machine/v1.ts b/src/server/plugins/engine/outputFormatters/machine/v1.ts index e888d428f..2098fce64 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v1.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v1.ts @@ -1,4 +1,7 @@ -import { type SubmitResponsePayload } from '@defra/forms-model' +import { + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { config } from '~/src/config/index.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers.js' @@ -19,7 +22,8 @@ export function format( items: DetailItem[], model: FormModel, _submitResponse: SubmitResponsePayload, - _formStatus: ReturnType + _formStatus: ReturnType, + _formMetadata?: FormMetadata ) { const now = new Date()