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/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/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts new file mode 100644 index 000000000..9264de319 --- /dev/null +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -0,0 +1,53 @@ +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' +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, + formMetadata?: FormMetadata +): string { + const v2DataString = machineV2( + context, + items, + model, + submitResponse, + formStatus + ) + const v2DataParsed = JSON.parse(v2DataString) as { + data: FormAdapterSubmissionMessageData + } + + const payload: FormAdapterSubmissionMessagePayload = { + meta: { + schemaVersion: FormAdapterSubmissionSchemaVersion.V1, + timestamp: new Date(), + referenceNumber: context.referenceNumber, + formName: model.name, + formId: formMetadata?.id ?? '', + formSlug: formMetadata?.slug ?? '', + status: formStatus.isPreview ? FormStatus.Draft : FormStatus.Live, + isPreview: formStatus.isPreview, + notificationEmail: formMetadata?.notificationEmail ?? '' + }, + data: v2DataParsed.data + } + + return JSON.stringify(payload) +} 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/index.ts b/src/server/plugins/engine/outputFormatters/index.ts index 6805cf104..4356dea02 100644 --- a/src/server/plugins/engine/outputFormatters/index.ts +++ b/src/server/plugins/engine/outputFormatters/index.ts @@ -1,8 +1,12 @@ -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' 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' @@ -13,12 +17,13 @@ type Formatter = ( items: DetailItem[], model: FormModel, submitResponse: SubmitResponsePayload, - formStatus: ReturnType + formStatus: ReturnType, + formMetadata?: FormMetadata ) => string const formatters: Record< string, - Record | undefined + Record | undefined > = { human: { '1': formatHumanV1 @@ -26,6 +31,9 @@ const formatters: Record< machine: { '1': formatMachineV1, '2': formatMachineV2 + }, + adapter: { + '1': formatAdapterV1 } } 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() 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/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.test.ts b/src/server/plugins/engine/services/notifyService.test.ts index f382b3c08..720ce6d4d 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,153 @@ 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') + } + }) + ) + }) + + 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/services/notifyService.ts b/src/server/plugins/engine/services/notifyService.ts index 7b99b8e5d..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' @@ -12,14 +16,24 @@ 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 + submitResponse: SubmitResponsePayload, + formMetadata?: FormMetadata ) { + if (!templateId) { + return Promise.resolve() + } + const logTags = ['submit', 'email'] const formStatus = checkFormStatus(request.params) @@ -35,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/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3a566d6ff..dbb5087d9 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,51 @@ export interface PluginOptions { onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" } + +export interface FormAdapterSubmissionMessageMeta { + schemaVersion: FormAdapterSubmissionSchemaVersion + timestamp: Date + referenceNumber: string + formName: string + formId: string + formSlug: string + status: FormStatus + isPreview: boolean + notificationEmail: string +} + +export type FormAdapterSubmissionMessageMetaSerialised = Omit< + FormAdapterSubmissionMessageMeta, + 'schemaVersion' | 'timestamp' | 'status' +> & { + schemaVersion: string + status: string + timestamp: string +} + +export interface FormAdapterSubmissionMessageData { + main: Record + repeaters: Record[]> + files: Record[]> +} + +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 new file mode 100644 index 000000000..c00c524e0 --- /dev/null +++ b/src/server/plugins/engine/types/index.ts @@ -0,0 +1,96 @@ +export type { + CheckAnswers, + ErrorMessageTemplate, + ErrorMessageTemplateList, + FeaturedFormPageViewModel, + FileState, + FilterFunction, + FormAdapterSubmissionMessage, + FormAdapterSubmissionMessageData, + FormAdapterSubmissionMessageMeta, + FormAdapterSubmissionMessageMetaSerialised, + FormAdapterSubmissionMessagePayload, + FormAdapterSubmissionService, + 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, + FormAdapterSubmissionSchemaVersion, + 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' + +export * from '~/src/server/plugins/engine/types/schema.js' 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() + }) + }) +}) 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() + }) 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 }