diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 12b6dc6c8..e4657c42d 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -1,4 +1,7 @@ -import { type FormMetadata } from '@defra/forms-model' +import { + type FormMetadata, + type SubmitResponsePayload +} from '@defra/forms-model' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' import { type Field } from '~/src/server/plugins/engine/components/helpers/components.js' @@ -26,7 +29,7 @@ const submitResponse = { files: { main: '00000000-0000-0000-0000-000000000000', repeaters: { - pizza: '11111111-1111-1111-1111-111111111111' + exampleRepeat: '11111111-1111-1111-1111-111111111111' } } } @@ -258,15 +261,26 @@ describe('Adapter v1 formatter', () => { exampleFile1: [ { fileId: '123-456-789', + fileName: 'foobar.txt', userDownloadLink: 'https://forms-designer/file-download/123-456-789' }, { fileId: '456-789-123', + fileName: 'bazbuzz.txt', userDownloadLink: 'https://forms-designer/file-download/456-789-123' } ] } }) + + expect(parsedBody.result).toEqual({ + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: { + exampleRepeat: '11111111-1111-1111-1111-111111111111' + } + } + }) }) it('should handle preview form status correctly', () => { @@ -385,6 +399,14 @@ describe('Adapter v1 formatter', () => { expect(parsedBody.data.main).toEqual({}) expect(parsedBody.data.repeaters).toEqual({}) expect(parsedBody.data.files).toEqual({}) + expect(parsedBody.result).toEqual({ + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: { + exampleRepeat: '11111111-1111-1111-1111-111111111111' + } + } + }) }) it('should handle different form statuses', () => { @@ -503,4 +525,172 @@ describe('Adapter v1 formatter', () => { expect(parsedBody.meta.formSlug).toBe('') expect(parsedBody.meta.notificationEmail).toBe('only-email@example.com') }) + + it('should include CSV file IDs from submitResponse.result.files', () => { + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format(context, items, model, submitResponse, formStatus) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.data.main).toEqual({ + exampleField: 'hello world', + exampleField2: 'hello world' + }) + + expect(parsedBody.data.repeaters.exampleRepeat).toEqual([ + { + subItem1_1: 'hello world', + subItem1_2: 'hello world' + }, + { + subItem2_1: 'hello world' + } + ]) + + expect(parsedBody.data.files.exampleFile1).toEqual([ + { + fileId: '123-456-789', + fileName: 'foobar.txt', + userDownloadLink: 'https://forms-designer/file-download/123-456-789' + }, + { + fileId: '456-789-123', + fileName: 'bazbuzz.txt', + userDownloadLink: 'https://forms-designer/file-download/456-789-123' + } + ]) + + expect(parsedBody.result).toEqual({ + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: { + exampleRepeat: '11111111-1111-1111-1111-111111111111' + } + } + }) + }) + + it('should handle submitResponse without CSV file IDs gracefully', () => { + const submitResponseWithoutFiles = { + message: 'Submit completed', + result: { + files: { + main: '', + repeaters: {} + } + } + } + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponseWithoutFiles, + formStatus + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.data.main).toEqual({ + exampleField: 'hello world', + exampleField2: 'hello world' + }) + + expect(parsedBody.data.repeaters.exampleRepeat).toEqual([ + { + subItem1_1: 'hello world', + subItem1_2: 'hello world' + }, + { + subItem2_1: 'hello world' + } + ]) + }) + + it('should handle submitResponse with only main CSV file ID', () => { + const submitResponseWithMainOnly = { + message: 'Submit completed', + result: { + files: { + main: 'main-only-file-id', + repeaters: {} + } + } + } + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponseWithMainOnly, + formStatus + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.data.main).toEqual({ + exampleField: 'hello world', + exampleField2: 'hello world' + }) + + expect(parsedBody.data.repeaters.exampleRepeat).toEqual([ + { + subItem1_1: 'hello world', + subItem1_2: 'hello world' + }, + { + subItem2_1: 'hello world' + } + ]) + + expect(parsedBody.result).toEqual({ + files: { + main: 'main-only-file-id', + repeaters: {} + } + }) + }) + + it('should handle submitResponse with missing repeaters property', () => { + const submitResponseWithoutRepeaters = { + message: 'Submit completed', + result: { + files: { + main: 'main-only-file-id' + } + } + } + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + const body = format( + context, + items, + model, + submitResponseWithoutRepeaters as unknown as SubmitResponsePayload, + formStatus + ) + const parsedBody = JSON.parse(body) as FormAdapterSubmissionMessagePayload + + expect(parsedBody.result).toEqual({ + files: { + main: 'main-only-file-id', + repeaters: {} + } + }) + }) }) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.ts index 6efacb704..a2fc5ab47 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.ts @@ -10,7 +10,9 @@ import { format as machineV2 } from '~/src/server/plugins/engine/outputFormatter import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/enums.js' import { type FormAdapterSubmissionMessageData, + type FormAdapterSubmissionMessageMeta, type FormAdapterSubmissionMessagePayload, + type FormAdapterSubmissionMessageResult, type FormContext } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' @@ -34,20 +36,44 @@ export function format( data: FormAdapterSubmissionMessageData } + const csvFiles = extractCsvFiles(submitResponse) + + const transformedData = v2DataParsed.data + + const meta: FormAdapterSubmissionMessageMeta = { + 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 ?? '' + } + const data: FormAdapterSubmissionMessageData = transformedData + + const result: FormAdapterSubmissionMessageResult = { + files: csvFiles + } + 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 + meta, + data, + result } return JSON.stringify(payload) } + +function extractCsvFiles( + submitResponse: SubmitResponsePayload +): FormAdapterSubmissionMessageResult['files'] { + const result = + submitResponse.result as Partial + + return { + main: result.files?.main ?? '', + repeaters: result.files?.repeaters ?? {} + } +} diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts index cfd85ff89..a29a99ff3 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts @@ -249,10 +249,12 @@ describe('getPersonalisation', () => { exampleFile1: [ { fileId: '123-456-789', + fileName: 'foobar.txt', userDownloadLink: 'https://forms-designer/file-download/123-456-789' }, { fileId: '456-789-123', + fileName: 'bazbuzz.txt', userDownloadLink: 'https://forms-designer/file-download/456-789-123' } ] diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index 5e2586a8e..215fb33d0 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -1,12 +1,7 @@ import { type SubmitResponsePayload } from '@defra/forms-model' import { config } from '~/src/config/index.js' -import { type UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js' import { FileUploadField } from '~/src/server/plugins/engine/components/index.js' -import { - type DatePartsState, - type MonthYearState -} from '~/src/server/plugins/engine/components/types.js' import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { @@ -15,9 +10,10 @@ import { type DetailItemRepeat } from '~/src/server/plugins/engine/models/types.js' import { + type FileUploadFieldDetailitem, + type FormAdapterFile, type FormContext, - type FormPayload, - type FormValue + type RichFormValue } from '~/src/server/plugins/engine/types.js' const designerUrl = config.get('designerUrl') @@ -69,7 +65,8 @@ export function format( * fileComponentName: [ * { * fileId: '123-456-789', - * link: 'https://forms-designer/file-download/123-456-789' + * fileName: 'example.pdf', + * userDownloadLink: 'https://forms-designer/file-download/123-456-789' * } * ] * } @@ -79,7 +76,10 @@ function categoriseData(items: DetailItem[]) { const output: { main: Record repeaters: Record[]> - files: Record[]> + files: Record< + string, + { fileId: string; fileName: string; userDownloadLink: string }[] + > } = { main: {}, repeaters: {}, files: {} } items.forEach((item) => { @@ -126,13 +126,17 @@ function extractRepeaters(item: DetailItemRepeat) { * @param item - the file upload item in the form * @returns the file upload data */ -function extractFileUploads(item: FileUploadFieldDetailitem) { - const fileUploadState = item.field.getContextValueFromState(item.state) ?? [] +function extractFileUploads( + item: FileUploadFieldDetailitem +): FormAdapterFile[] { + const fileUploadState = item.field.getFormValueFromState(item.state) ?? [] - return fileUploadState.map((fileId) => { + return fileUploadState.map((fileState) => { + const { file } = fileState.status.form return { - fileId, - userDownloadLink: `${designerUrl}/file-download/${fileId}` + fileId: file.fileId, + fileName: file.filename, + userDownloadLink: `${designerUrl}/file-download/${file.fileId}` } }) } @@ -142,17 +146,3 @@ function isFileUploadFieldItem( ): item is FileUploadFieldDetailitem { return item.field instanceof FileUploadField } - -/** - * A detail item specifically for files - */ -type FileUploadFieldDetailitem = Omit & { - field: FileUploadField -} - -export type RichFormValue = - | FormValue - | FormPayload - | DatePartsState - | MonthYearState - | UkAddressState diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index dc381cd86..91b98676a 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -11,14 +11,18 @@ import { type PluginProperties, type Request } from '@hapi/hapi' import { type JoiExpression, type ValidationErrorItem } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js' import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js' +import { type FileUploadField } from '~/src/server/plugins/engine/components/index.js' import { type BackLink, type ComponentText, - type ComponentViewModel + type ComponentViewModel, + type DatePartsState, + type MonthYearState } 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 DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' import { @@ -405,16 +409,42 @@ export type FormAdapterSubmissionMessageMetaSerialised = Omit< status: string timestamp: string } +export interface FormAdapterFile { + fileName: string + fileId: string + userDownloadLink: string +} + +export interface FormAdapterSubmissionMessageResult { + files: { + main: string + repeaters: Record + } +} + +/** + * A detail item specifically for files + */ +export type FileUploadFieldDetailitem = Omit & { + field: FileUploadField +} +export type RichFormValue = + | FormValue + | FormPayload + | DatePartsState + | MonthYearState + | UkAddressState export interface FormAdapterSubmissionMessageData { main: Record repeaters: Record[]> - files: Record[]> + files: Record } export interface FormAdapterSubmissionMessagePayload { meta: FormAdapterSubmissionMessageMeta data: FormAdapterSubmissionMessageData + result: FormAdapterSubmissionMessageResult } export interface FormAdapterSubmissionMessage diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index 917f045db..e7e7e00f1 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -87,7 +87,5 @@ export type { 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' export { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/types/enums.js' diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index b5f2e8a73..4bbd324b1 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -9,10 +9,56 @@ import { import { type FormAdapterSubmissionMessageData, type FormAdapterSubmissionMessageMeta, - type FormAdapterSubmissionMessagePayload + type FormAdapterSubmissionMessagePayload, + type RichFormValue } from '~/src/server/plugins/engine/types.js' describe('Schema validation', () => { + const main: Record = { + 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'] + } + + const value1: Record = { + IEKzko: 'dsfsdfsdf' + } + const value2: Record = { + IEKzko: 'dfghfgh' + } + + const validData: FormAdapterSubmissionMessageData = { + main, + repeaters: { + qLVLgb: [value1, value2] + }, + files: { + dLzALM: [ + { + fileId: '489ecc1b-a145-4618-ba5a-b4a0d5ee2dbd', + fileName: 'file-name.json', + userDownloadLink: + 'http://localhost:3005/file-download/489ecc1b-a145-4618-ba5a-b4a0d5ee2dbd' + } + ] + } + } + describe('formAdapterSubmissionMessageMetaSchema', () => { const validMeta: FormAdapterSubmissionMessageMeta = { schemaVersion: FormAdapterSubmissionSchemaVersion.V1, @@ -49,31 +95,6 @@ describe('Schema validation', () => { }) 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) @@ -89,41 +110,28 @@ describe('Schema validation', () => { }) describe('formAdapterSubmissionMessagePayloadSchema', () => { + const meta: 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' + } + 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: {} + meta, + data: validData, + result: { + files: { + main: '3d289230-83a3-4852-a68a-cb3569e9b0fe', + repeaters: { + ImxIOP: 'Joe Bloggs' + } + } } } diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index 039ce2082..57331bd1e 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -11,7 +11,8 @@ import { FormAdapterSubmissionSchemaVersion } from '~/src/server/plugins/engine/ import { type FormAdapterSubmissionMessageData, type FormAdapterSubmissionMessageMeta, - type FormAdapterSubmissionMessagePayload + type FormAdapterSubmissionMessagePayload, + type FormAdapterSubmissionMessageResult } from '~/src/server/plugins/engine/types.js' export const formAdapterSubmissionMessageMetaSchema = @@ -35,11 +36,31 @@ export const formAdapterSubmissionMessageDataSchema = Joi.object().keys({ main: Joi.object(), repeaters: Joi.object(), + files: Joi.object().pattern( + Joi.string(), + Joi.array().items( + Joi.object().keys({ + fileName: Joi.string().required(), + fileId: Joi.string().required(), + userDownloadLink: Joi.string().required() + }) + ) + ) + }) + +export const formAdapterSubmissionMessageResultSchema = + Joi.object().keys({ files: Joi.object() + .keys({ + main: Joi.string().required(), + repeaters: Joi.object() + }) + .required() }) export const formAdapterSubmissionMessagePayloadSchema = Joi.object().keys({ meta: formAdapterSubmissionMessageMetaSchema.required(), - data: formAdapterSubmissionMessageDataSchema.required() + data: formAdapterSubmissionMessageDataSchema.required(), + result: formAdapterSubmissionMessageResultSchema.required() })