diff --git a/packages/wallet/backend/migrations/20260707092438_update_field_definitions.js b/packages/wallet/backend/migrations/20260707092438_update_field_definitions.js new file mode 100644 index 000000000..ea3e135d0 --- /dev/null +++ b/packages/wallet/backend/migrations/20260707092438_update_field_definitions.js @@ -0,0 +1,80 @@ +const fields = [ + { + key: 'telephoneNumber', + label: 'Telephone number', + type: 'tel', + required: true, + placeholder: '1234567890', + order: 4, + minLength: 9, + pattern: '^[0-9]+$' + }, + { + key: 'businessName', + label: 'Business name', + type: 'text', + required: true, + placeholder: 'Enter your business name', + order: 3, + minLength: 3, + maxLength: 40 + }, + { + key: 'deviceId', + label: 'Device ID', + type: 'number', + required: true, + placeholder: 'Enter your device ID', + order: 6, + min: 2, + max: 15 + }, + { + key: 'acceptTerms', + label: 'Accept Terms and Conditions', + type: 'checkbox', + required: true, + order: 7, + mustEqual: true + }, + { + key: 'fiscalYear', + label: 'Fiscal Year', + type: 'date', + placeholder: 'YYYY-MM-DD', + order: 5 + } +] + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + await knex.schema.alterTable('field_definitions', (table) => { + table.integer('minLength').nullable() + table.string('pattern').nullable() + table.integer('min').nullable() + table.integer('max').nullable() + table.boolean('mustEqual').nullable() + }) + + return knex('field_definitions').insert(fields) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function (knex) { + const keys = fields.map((f) => f.key) + await knex('field_definitions').whereIn('key', keys).del() + + return knex.schema.alterTable('field_definitions', (table) => { + table.dropColumn('minLength') + table.dropColumn('pattern') + table.dropColumn('min') + table.dropColumn('max') + table.dropColumn('mustEqual') + }) +} diff --git a/packages/wallet/backend/src/terminal/controller.ts b/packages/wallet/backend/src/terminal/controller.ts index 1f9cf4fa7..cf4e89d6d 100644 --- a/packages/wallet/backend/src/terminal/controller.ts +++ b/packages/wallet/backend/src/terminal/controller.ts @@ -6,13 +6,39 @@ export class TerminalController { constructor(private terminalService: TerminalService) {} getOnboardingFormDefinition = async ( - _req: Request, + req: Request, res: Response, next: NextFunction ) => { try { + const full = String(req.query?.full) === 'true' + + if (full) { + const formDefinition = + await this.terminalService.getAllOnboardingFormDefinitions() + + return res.status(200).json(toSuccessResponse(formDefinition)) + } + + const minimalKeys = ['merchantCategoryCode', 'contactEmail'] const formDefinition = await this.terminalService.getOnboardingFormDefinition() + const filtered = formDefinition.filter((f) => minimalKeys.includes(f.key)) + + return res.status(200).json(toSuccessResponse(filtered)) + } catch (error) { + next(error) + } + } + + getAllOnboardingFormDefinition = async ( + _req: Request, + res: Response, + next: NextFunction + ) => { + try { + const formDefinition = + await this.terminalService.getAllOnboardingFormDefinitions() res.status(200).json(toSuccessResponse(formDefinition)) } catch (error) { next(error) diff --git a/packages/wallet/backend/src/terminal/model.ts b/packages/wallet/backend/src/terminal/model.ts index ec30d2a9e..8186f247f 100644 --- a/packages/wallet/backend/src/terminal/model.ts +++ b/packages/wallet/backend/src/terminal/model.ts @@ -35,6 +35,11 @@ export class FieldDefinitions extends BaseModel { public validation?: Validation public format?: string public maxLength?: number + public minLength?: number + public pattern?: string + public min?: number + public max?: number + public mustEqual?: boolean static relationMappings = () => ({ options: { diff --git a/packages/wallet/backend/src/terminal/service.ts b/packages/wallet/backend/src/terminal/service.ts index c5bf577a6..af2596aa4 100644 --- a/packages/wallet/backend/src/terminal/service.ts +++ b/packages/wallet/backend/src/terminal/service.ts @@ -44,4 +44,70 @@ export class TerminalService { return mapped as FieldDefinitions }) } + + async getAllOnboardingFormDefinitions(): Promise { + const fields = await FieldDefinitions.query() + .withGraphFetched('options') + .orderBy('order', 'asc') + + this.logger.debug('Returning merchant onboarding form definition', { + fields: fields + }) + + return fields.map((field) => { + const mapped = { + key: field.key, + label: field.label, + type: field.type, + required: field.required, + order: field.order + } as Partial + + if (field.description) mapped.description = field.description + if (field.placeholder) mapped.placeholder = field.placeholder + if (field.format) + mapped.validation = { + ...(mapped.validation || {}), + format: field.format + } + if (field.maxLength) + mapped.validation = { + ...(mapped.validation || {}), + maxLength: field.maxLength + } + if (field.minLength) + mapped.validation = { + ...(mapped.validation || {}), + minLength: field.minLength + } + if (field.pattern) + mapped.validation = { + ...(mapped.validation || {}), + pattern: field.pattern + } + if (field.min) + mapped.validation = { + ...(mapped.validation || {}), + min: field.min + } + if (field.max) + mapped.validation = { + ...(mapped.validation || {}), + max: field.max + } + if (field.mustEqual) + mapped.validation = { + ...(mapped.validation || {}), + mustEqual: field.mustEqual + } + if (field.options?.length) { + mapped.options = field.options.map((opt) => ({ + ...(opt.value && { value: opt.value }), + ...(opt.label && { label: opt.label }) + })) as FieldDefinitions['options'] + } + + return mapped as FieldDefinitions + }) + } } diff --git a/packages/wallet/backend/tests/terminal/controller.test.ts b/packages/wallet/backend/tests/terminal/controller.test.ts index 1e3013d08..bd94c43fa 100644 --- a/packages/wallet/backend/tests/terminal/controller.test.ts +++ b/packages/wallet/backend/tests/terminal/controller.test.ts @@ -11,7 +11,8 @@ import { FieldDefinitions } from '@/terminal/model' describe('Terminal Controller', () => { const mockTerminalService = { - getOnboardingFormDefinition: jest.fn() + getOnboardingFormDefinition: jest.fn(), + getAllOnboardingFormDefinitions: jest.fn() } let terminalController: TerminalController @@ -20,6 +21,7 @@ describe('Terminal Controller', () => { const next = jest.fn() beforeEach(() => { + jest.clearAllMocks() terminalController = new TerminalController( mockTerminalService as unknown as TerminalService ) @@ -27,11 +29,11 @@ describe('Terminal Controller', () => { res = createResponse() }) - it('should return onboarding form definition', async () => { + it('should return the minimal onboarding fields by default', async () => { const formDefinition = [ { - key: 'mockContactEmail', - label: 'Mock contact email', + key: 'contactEmail', + label: 'Contact email', description: 'We use this to send onboarding confirmation.', type: 'email', required: true, @@ -42,8 +44,8 @@ describe('Terminal Controller', () => { }, { id: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', - key: 'mockMerchantCategoryCode', - label: 'Mock merchant category', + key: 'merchantCategoryCode', + label: 'Merchant category', type: 'select', required: true, order: 1 @@ -57,10 +59,103 @@ describe('Terminal Controller', () => { await terminalController.getOnboardingFormDefinition(req, res, next) expect(mockTerminalService.getOnboardingFormDefinition).toHaveBeenCalled() + expect( + mockTerminalService.getAllOnboardingFormDefinitions + ).not.toHaveBeenCalled() + expect(res.statusCode).toBe(200) + const responseBody = res._getJSONData() + expect(responseBody).toMatchObject({ success: true }) + expect(responseBody.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'contactEmail' }), + expect.objectContaining({ key: 'merchantCategoryCode' }) + ]) + ) + expect(responseBody.result).toHaveLength(2) + expect(next).not.toHaveBeenCalled() + }) + + it('should return the full onboarding form', async () => { + const fullFormDefinition = [ + { + key: 'mockContactEmail', + label: 'Mock contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email', + required: true, + placeholder: 'me@interledger.org', + order: 2, + format: 'email', + maxLength: 255 + }, + { + id: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category', + type: 'select', + required: true, + order: 1 + }, + { + key: 'mockBusinessName', + label: 'Mock business name', + type: 'text', + required: true, + placeholder: 'Enter your business name', + order: 3, + minLength: 3, + maxLength: 40 + }, + { + key: 'mockTelephoneNumber', + label: 'Mock telephone number', + type: 'tel', + required: true, + placeholder: '1234567890', + order: 4, + minLength: 9, + pattern: '^[0-9]+$' + }, + { + key: 'deviceId', + label: 'Device ID', + type: 'number', + required: true, + placeholder: 'Enter your device ID', + order: 6, + min: 2, + max: 15 + }, + { + key: 'acceptTerms', + label: 'Accept Terms and Conditions', + type: 'checkbox', + required: true, + order: 7, + mustEqual: true + }, + { + key: 'fiscalYear', + label: 'Fiscal Year', + type: 'date', + placeholder: 'YYYY-MM-DD', + order: 5 + } + ] as unknown as FieldDefinitions[] + + mockTerminalService.getAllOnboardingFormDefinitions.mockResolvedValue( + fullFormDefinition + ) + + await terminalController.getAllOnboardingFormDefinition(req, res, next) + + expect( + mockTerminalService.getAllOnboardingFormDefinitions + ).toHaveBeenCalled() expect(res.statusCode).toBe(200) expect(res._getJSONData()).toMatchObject({ success: true, - result: formDefinition + result: fullFormDefinition }) expect(next).not.toHaveBeenCalled() }) diff --git a/packages/wallet/backend/tests/terminal/service.test.ts b/packages/wallet/backend/tests/terminal/service.test.ts index 73607b152..54aa9236f 100644 --- a/packages/wallet/backend/tests/terminal/service.test.ts +++ b/packages/wallet/backend/tests/terminal/service.test.ts @@ -39,38 +39,223 @@ describe('Terminal Service', () => { } ] - beforeEach(() => { + const expectedFormDefinition = [ + { + key: 'mockContactEmail', + label: 'Mock contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email', + required: true, + placeholder: 'me@interledger.com', + order: 2, + validation: { + format: 'email', + maxLength: 255 + } + }, + { + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category', + type: 'select', + required: true, + order: 1, + options: [ + { + value: '5311', + label: 'Department stores' + } + ] + } + ] + + const mockFullFieldDefinitions = [ + { + id: '1', + key: 'mockContactEmail', + label: 'Mock contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email' as const, + required: true, + placeholder: 'me@interledger.org', + order: 2, + format: 'email', + maxLength: 255 + }, + { + id: '2', + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category', + type: 'select' as const, + required: true, + order: 1, + options: [ + { + id: 'opt-1', + value: '5311', + label: 'Department stores' + } + ] + }, + { + id: '3', + key: 'mockBusinessName', + label: 'Mock business name', + type: 'text' as const, + required: true, + placeholder: 'Enter your business name', + order: 3, + minLength: 3, + maxLength: 40 + }, + { + id: '4', + key: 'mockTelephoneNumber', + label: 'Mock telephone number', + type: 'tel' as const, + required: true, + placeholder: '1234567890', + order: 4, + minLength: 9, + pattern: '^[0-9]+$' + }, + { + id: '5', + key: 'mockDeviceId', + label: 'Mock device ID', + type: 'number' as const, + required: true, + placeholder: 'Enter your device ID', + order: 6, + min: 2, + max: 15 + }, + { + id: '6', + key: 'mockAcceptTerms', + label: 'Mock accept terms', + type: 'checkbox' as const, + required: true, + order: 7, + mustEqual: true + }, + { + id: '7', + key: 'mockFiscalYear', + label: 'Mock fiscal year', + type: 'date' as const, + placeholder: 'YYYY-MM-DD', + order: 5 + } + ] + + const expectedAllFormDefinitions = [ + { + key: 'mockContactEmail', + label: 'Mock contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email', + required: true, + placeholder: 'me@interledger.org', + order: 2, + validation: { + format: 'email', + maxLength: 255 + } + }, + { + key: 'mockMerchantCategoryCode', + label: 'Mock merchant category', + type: 'select', + required: true, + order: 1, + options: [ + { + value: '5311', + label: 'Department stores' + } + ] + }, + { + key: 'mockBusinessName', + label: 'Mock business name', + type: 'text', + required: true, + placeholder: 'Enter your business name', + order: 3, + validation: { + minLength: 3, + maxLength: 40 + } + }, + { + key: 'mockTelephoneNumber', + label: 'Mock telephone number', + type: 'tel', + required: true, + placeholder: '1234567890', + order: 4, + validation: { + minLength: 9, + pattern: '^[0-9]+$' + } + }, + { + key: 'mockDeviceId', + label: 'Mock device ID', + type: 'number', + required: true, + placeholder: 'Enter your device ID', + order: 6, + validation: { + min: 2, + max: 15 + } + }, + { + key: 'mockAcceptTerms', + label: 'Mock accept terms', + type: 'checkbox', + required: true, + order: 7, + validation: { + mustEqual: true + } + }, + { + key: 'mockFiscalYear', + label: 'Mock fiscal year', + type: 'date', + placeholder: 'YYYY-MM-DD', + order: 5 + } + ] + + it('should return the onboarding form definition', async () => { const mockQueryBuilder = { withGraphFetched: jest.fn().mockReturnThis(), orderBy: jest.fn().mockResolvedValue(mockFieldDefinitions) } as unknown as ReturnType jest.spyOn(FieldDefinitions, 'query').mockReturnValue(mockQueryBuilder) - }) - it('should return the onboarding form definition', async () => { const terminalService = new TerminalService(mockLogger) const formDefinition = await terminalService.getOnboardingFormDefinition() - expect(formDefinition).toEqual([ - { - key: 'mockContactEmail', - label: 'Mock contact email', - type: 'email', - required: true, - order: 2, - description: 'We use this to send onboarding confirmation.', - placeholder: 'me@interledger.com', - validation: { format: 'email', maxLength: 255 } - }, - { - key: 'mockMerchantCategoryCode', - label: 'Mock merchant category', - type: 'select', - required: true, - order: 1, - options: [{ value: '5311', label: 'Department stores' }] - } - ]) + expect(formDefinition).toEqual(expectedFormDefinition) + }) + + it('should return all onboarding form definitions', async () => { + const mockQueryBuilder = { + withGraphFetched: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockResolvedValue(mockFullFieldDefinitions) + } as unknown as ReturnType + + jest.spyOn(FieldDefinitions, 'query').mockReturnValue(mockQueryBuilder) + + const terminalService = new TerminalService(mockLogger) + const formDefinitions = + await terminalService.getAllOnboardingFormDefinitions() + + expect(formDefinitions).toEqual(expectedAllFormDefinitions) }) })