@defra/fcp-sfd-frontend-engine 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ import Joi from 'joi'
2
+ import {
3
+ ADDRESS_LINE_MAX,
4
+ TOWN_CITY_MAX,
5
+ COUNTY_MAX,
6
+ POSTCODE_MAX,
7
+ COUNTRY_MAX
8
+ } from '../constants/validation-fields.js'
9
+
10
+ export const addressSchema = Joi.object({
11
+ address1: Joi.string()
12
+ .required()
13
+ .max(ADDRESS_LINE_MAX)
14
+ .messages({
15
+ 'string.empty': 'Enter address line 1, typically the building and street',
16
+ 'string.max': `Address line 1 must be ${ADDRESS_LINE_MAX} characters or less`,
17
+ 'any.required': 'Enter address line 1, typically the building and street'
18
+ }),
19
+ address2: Joi.string()
20
+ .allow('')
21
+ .max(ADDRESS_LINE_MAX)
22
+ .messages({
23
+ 'string.max': `Address line 2 must be ${ADDRESS_LINE_MAX} characters or less`
24
+ }),
25
+ address3: Joi.string()
26
+ .allow('')
27
+ .max(ADDRESS_LINE_MAX)
28
+ .messages({
29
+ 'string.max': `Address line 3 must be ${ADDRESS_LINE_MAX} characters or less`
30
+ }),
31
+ city: Joi.string()
32
+ .required()
33
+ .max(TOWN_CITY_MAX)
34
+ .messages({
35
+ 'string.empty': 'Enter town or city',
36
+ 'string.max': `Town or city must be ${TOWN_CITY_MAX} characters or less`,
37
+ 'any.required': 'Enter town or city'
38
+ }),
39
+ county: Joi.string()
40
+ .allow('')
41
+ .max(COUNTY_MAX)
42
+ .messages({
43
+ 'string.max': `County must be ${COUNTY_MAX} characters or less`
44
+ }),
45
+ postcode: Joi.string()
46
+ .required()
47
+ .max(POSTCODE_MAX)
48
+ .messages({
49
+ 'any.required': 'Enter a postal code or zip code',
50
+ 'string.empty': 'Enter a postal code or zip code',
51
+ 'string.max': `Postal code or zip code must be ${POSTCODE_MAX} characters or less`
52
+ }),
53
+ country: Joi.string()
54
+ .required()
55
+ .max(COUNTRY_MAX)
56
+ .messages({
57
+ 'string.empty': 'Enter a country',
58
+ 'string.max': `Country must be ${COUNTRY_MAX} characters or less`,
59
+ 'any.required': 'Enter a country'
60
+ })
61
+ })
@@ -0,0 +1,19 @@
1
+ import Joi from 'joi'
2
+ import { EMAIL_MAX } from '../../constants/validation-fields.js'
3
+
4
+ export const businessEmailSchema = Joi.object({
5
+ businessEmail: Joi.string()
6
+ .required()
7
+ .max(EMAIL_MAX)
8
+ .email({
9
+ minDomainSegments: 2,
10
+ tlds: {
11
+ allow: true
12
+ }
13
+ })
14
+ .messages({
15
+ 'string.max': `Business email address must be ${EMAIL_MAX} characters or less`,
16
+ 'string.empty': 'Enter business email address',
17
+ 'string.email': 'Enter an email address, like name@example.com'
18
+ })
19
+ })
@@ -0,0 +1,13 @@
1
+ import Joi from 'joi'
2
+ import { BUSINESS_NAME_MAX } from '../../constants/validation-fields.js'
3
+
4
+ export const businessNameSchema = Joi.object({
5
+ businessName: Joi.string()
6
+ .required()
7
+ .max(BUSINESS_NAME_MAX)
8
+ .messages({
9
+ 'string.empty': 'Enter business name',
10
+ 'string.max': `Business name must be ${BUSINESS_NAME_MAX} characters or less`,
11
+ 'any.required': 'Enter business name'
12
+ })
13
+ })
@@ -0,0 +1,33 @@
1
+ import Joi from 'joi'
2
+ import {
3
+ PHONE_NUMBER_MIN,
4
+ PHONE_NUMBER_MAX
5
+ } from '../../constants/validation-fields.js'
6
+ import { PHONE_NUMBER_PATTERN } from '../../constants/patterns.js'
7
+
8
+ export const businessPhoneSchema = Joi.object({
9
+ businessTelephone: Joi.string()
10
+ .empty('')
11
+ .min(PHONE_NUMBER_MIN)
12
+ .max(PHONE_NUMBER_MAX)
13
+ .pattern(PHONE_NUMBER_PATTERN)
14
+ .messages({
15
+ 'string.min': `Business telephone number must be ${PHONE_NUMBER_MIN} characters or more`,
16
+ 'string.max': `Business telephone number must be ${PHONE_NUMBER_MAX} characters or less`,
17
+ 'string.pattern.base': 'Business telephone number must only include numbers 0 to 9 and special characters such as spaces, brackets and +'
18
+ }),
19
+ businessMobile: Joi.string()
20
+ .empty('')
21
+ .min(PHONE_NUMBER_MIN)
22
+ .max(PHONE_NUMBER_MAX)
23
+ .pattern(PHONE_NUMBER_PATTERN)
24
+ .messages({
25
+ 'string.min': `Business mobile phone number must be ${PHONE_NUMBER_MIN} characters or more`,
26
+ 'string.max': `Business mobile phone number must be ${PHONE_NUMBER_MAX} characters or less`,
27
+ 'string.pattern.base': 'Business mobile phone number must only include numbers 0 to 9 and special characters such as spaces, brackets and +'
28
+ })
29
+ })
30
+ .or('businessTelephone', 'businessMobile')
31
+ .messages({
32
+ 'object.missing': 'Enter at least one phone number'
33
+ })
@@ -1,5 +1,18 @@
1
1
  import { businessSbiSchema } from './business-sbi-schema.js'
2
+ import { addressSchema } from '../address-schema.js'
3
+ import { businessNameSchema } from './business-name-schema.js'
4
+ import { businessEmailSchema } from './business-email-schema.js'
5
+ import { businessPhoneSchema } from './business-phone-schema.js'
6
+ import { businessVatSchema } from './business-vat-schema.js'
2
7
 
3
8
  export const businessSchemas = {
4
- sbi: businessSbiSchema
9
+ sbi: businessSbiSchema,
10
+ address: addressSchema,
11
+ details: {
12
+ name: businessNameSchema,
13
+ address: addressSchema,
14
+ phone: businessPhoneSchema,
15
+ email: businessEmailSchema,
16
+ vat: businessVatSchema
17
+ }
5
18
  }
@@ -0,0 +1,11 @@
1
+ import Joi from 'joi'
2
+
3
+ export const businessVatSchema = Joi.object({
4
+ vatNumber: Joi.string()
5
+ .pattern(/^\d{9}$/)
6
+ .allow('')
7
+ .optional()
8
+ .messages({
9
+ 'string.pattern.base': 'Enter a VAT registration number, like 123456789'
10
+ })
11
+ })
@@ -0,0 +1,178 @@
1
+ import Joi from 'joi'
2
+ import { MONTH_MAP, MAX_AGE_YEARS } from '../../constants/validation-fields-export.js'
3
+
4
+ export const personalDobSchema = Joi.object({
5
+ day: Joi.string().allow(''),
6
+ month: Joi.string().allow(''),
7
+ year: Joi.string().allow('')
8
+ }).custom((value, helpers) => {
9
+ const { day, month, year } = value
10
+
11
+ // Check for missing fields or combinations
12
+ const missingFieldsError = checkMissingFields(day, month, year, helpers)
13
+ if (missingFieldsError) {
14
+ return missingFieldsError
15
+ }
16
+
17
+ // Year format check (must be 4 digits)
18
+ if (year && year.length !== 4) {
19
+ return makeError(helpers, 'dob.yearLength', ['year'])
20
+ }
21
+
22
+ // Normalise and validate month input (can be name, abbreviation, or number)
23
+ const monthValue = getMonthNumber(month, helpers)
24
+ if (monthValue.isJoiError) {
25
+ return monthValue
26
+ }
27
+
28
+ // Validate that a real, valid date exists
29
+ const fullDate = getFullDate(day, monthValue, year, helpers)
30
+ if (fullDate.isJoiError) {
31
+ return fullDate
32
+ }
33
+
34
+ // Date must be in the past
35
+ if (fullDate > new Date()) {
36
+ return makeError(helpers, 'dob.future', ['day', 'month', 'year'])
37
+ }
38
+
39
+ // Date must not be more than 120 years ago
40
+ const tooOldError = checkNotTooOld(fullDate, helpers)
41
+ if (tooOldError) {
42
+ return tooOldError
43
+ }
44
+
45
+ return value
46
+ }).messages({
47
+ 'dob.missingAll': 'Enter your date of birth',
48
+ 'dob.missingDay': 'Date of birth must include a day',
49
+ 'dob.missingMonth': 'Date of birth must include a month',
50
+ 'dob.missingYear': 'Date of birth must include a year',
51
+ 'dob.missingDayMonth': 'Date of birth must include a day and month',
52
+ 'dob.missingDayYear': 'Date of birth must include a day and year',
53
+ 'dob.missingMonthYear': 'Date of birth must include a month and year',
54
+ 'dob.yearLength': 'Enter a year with 4 numbers, like 1975',
55
+ 'dob.invalid': 'Date of birth must be a real date',
56
+ 'dob.future': 'Date of birth must be in the past',
57
+ 'dob.tooOld': 'Date of birth must be on or after {{#oldest}}'
58
+ })
59
+
60
+ const checkNotTooOld = (fullDate, helpers) => {
61
+ const oldestDateAllowed = getOldestAllowedDate()
62
+
63
+ if (fullDate < oldestDateAllowed) {
64
+ // Create date string from 120 years ago (to use for the error)
65
+ const oldestDateString = oldestDateAllowed.toLocaleDateString('en-GB', {
66
+ day: 'numeric',
67
+ month: 'long',
68
+ year: 'numeric'
69
+ })
70
+
71
+ // Pass the date string as context so the message template can use it
72
+ const error = helpers.error('dob.tooOld', { oldest: oldestDateString })
73
+ error.path = ['day', 'month', 'year']
74
+
75
+ return error
76
+ }
77
+
78
+ return null
79
+ }
80
+
81
+ const getOldestAllowedDate = () => {
82
+ const date = new Date()
83
+ date.setUTCHours(0, 0, 0, 0)
84
+ date.setUTCFullYear(date.getUTCFullYear() - MAX_AGE_YEARS)
85
+
86
+ return date
87
+ }
88
+
89
+ /**
90
+ * Gets and validates a full date (day, month, year) to ensure it is a real and valid date.
91
+ */
92
+ const getFullDate = (day, monthValue, year, helpers) => {
93
+ const dayValue = Number.parseInt(day, 10)
94
+ const yearValue = Number.parseInt(year, 10)
95
+ const date = new Date(Date.UTC(yearValue, monthValue - 1, dayValue))
96
+
97
+ if (
98
+ date.getUTCFullYear() !== yearValue ||
99
+ date.getUTCMonth() + 1 !== monthValue ||
100
+ date.getUTCDate() !== dayValue
101
+ ) {
102
+ return makeError(helpers, 'dob.invalid', ['day', 'month', 'year'])
103
+ }
104
+
105
+ return date
106
+ }
107
+
108
+ /**
109
+ * Normalises a month value entered by the user.
110
+ *
111
+ * A user can enter either:
112
+ * - a month number (e.g. "10")
113
+ * - a full month name (e.g. "October")
114
+ * - or a common abbreviation (e.g. "Oct")
115
+ *
116
+ * If the value is not numeric, it is converted to lowercase and looked up in the MONTH_MAP,
117
+ * which maps all valid month names and abbreviations to their corresponding number values
118
+ * (e.g. "january" → 1, "feb" → 2).
119
+ *
120
+ * If a valid mapping is found, the mapped number is returned.
121
+ * If no mapping exists, an error is returned via `makeError`, as the input is likely invalid.
122
+ * If the month is already numeric, it is simply parsed and returned as a number.
123
+ */
124
+ const getMonthNumber = (month, helpers) => {
125
+ if (Number.isNaN(Number(month))) {
126
+ const lower = month.toLowerCase()
127
+ const mapped = MONTH_MAP[lower]
128
+
129
+ if (!mapped) {
130
+ return makeError(helpers, 'dob.invalid', ['month'])
131
+ }
132
+
133
+ return mapped
134
+ }
135
+
136
+ return Number.parseInt(month, 10)
137
+ }
138
+
139
+ /**
140
+ * Validates combinations of missing date fields
141
+ * (e.g. day missing, month+year entered, etc.)
142
+ */
143
+ const checkMissingFields = (day, month, year, helpers) => {
144
+ if (!day && !month && !year) {
145
+ return makeError(helpers, 'dob.missingAll', ['day', 'month', 'year'])
146
+ }
147
+ if (!day && month && year) {
148
+ return makeError(helpers, 'dob.missingDay', ['day'])
149
+ }
150
+ if (day && !month && year) {
151
+ return makeError(helpers, 'dob.missingMonth', ['month'])
152
+ }
153
+ if (day && month && !year) {
154
+ return makeError(helpers, 'dob.missingYear', ['year'])
155
+ }
156
+ if (!day && !month && year) {
157
+ return makeError(helpers, 'dob.missingDayMonth', ['day', 'month'])
158
+ }
159
+ if (!day && month && !year) {
160
+ return makeError(helpers, 'dob.missingDayYear', ['day', 'year'])
161
+ }
162
+ if (day && !month && !year) {
163
+ return makeError(helpers, 'dob.missingMonthYear', ['month', 'year'])
164
+ }
165
+
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * Creates a Joi error and manually sets its path so the right input fields highlight.
171
+ */
172
+ const makeError = (helpers, code, fields) => {
173
+ const error = helpers.error(code)
174
+ error.path = fields
175
+ error.isJoiError = true
176
+
177
+ return error
178
+ }
@@ -0,0 +1,20 @@
1
+ import Joi from 'joi'
2
+ import { EMAIL_MAX } from '../../constants/validation-fields.js'
3
+
4
+ export const personalEmailSchema = Joi.object({
5
+ personalEmail: Joi.string()
6
+ .required()
7
+ .max(EMAIL_MAX)
8
+ .email({
9
+ minDomainSegments: 2,
10
+ tlds: {
11
+ allow: true,
12
+ min: 2
13
+ }
14
+ })
15
+ .messages({
16
+ 'string.max': `Email address must be ${EMAIL_MAX} characters or less`,
17
+ 'string.empty': 'Enter a personal email address',
18
+ 'string.email': 'Enter an email address, like name@example.com'
19
+ })
20
+ })
@@ -0,0 +1,31 @@
1
+ import Joi from 'joi'
2
+ import {
3
+ FIRST_NAME_MAX,
4
+ LAST_NAME_MAX,
5
+ MIDDLE_NAMES_MAX
6
+ } from '../../constants/validation-fields.js'
7
+
8
+ export const personalNameSchema = Joi.object({
9
+ first: Joi.string()
10
+ .required()
11
+ .max(FIRST_NAME_MAX)
12
+ .messages({
13
+ 'string.empty': 'Enter first name',
14
+ 'string.max': `First name must be ${FIRST_NAME_MAX} characters or less`,
15
+ 'any.required': 'Enter first name'
16
+ }),
17
+ last: Joi.string()
18
+ .required()
19
+ .max(LAST_NAME_MAX)
20
+ .messages({
21
+ 'string.empty': 'Enter last name',
22
+ 'string.max': `Last name must be ${LAST_NAME_MAX} characters or less`,
23
+ 'any.required': 'Enter last name'
24
+ }),
25
+ middle: Joi.string()
26
+ .allow('')
27
+ .max(MIDDLE_NAMES_MAX)
28
+ .messages({
29
+ 'string.max': `Middle names must be ${MIDDLE_NAMES_MAX} characters or less`
30
+ })
31
+ })
@@ -0,0 +1,33 @@
1
+ import Joi from 'joi'
2
+ import {
3
+ PHONE_NUMBER_MIN,
4
+ PHONE_NUMBER_MAX,
5
+ PHONE_NUMBER_PATTERN
6
+ } from '../../constants/validation-fields-export.js'
7
+
8
+ export const personalPhoneSchema = Joi.object({
9
+ personalTelephone: Joi.string()
10
+ .empty('')
11
+ .min(PHONE_NUMBER_MIN)
12
+ .max(PHONE_NUMBER_MAX)
13
+ .pattern(PHONE_NUMBER_PATTERN)
14
+ .messages({
15
+ 'string.min': `Personal telephone number must be ${PHONE_NUMBER_MIN} characters or more`,
16
+ 'string.max': `Personal telephone number must be ${PHONE_NUMBER_MAX} characters or less`,
17
+ 'string.pattern.base': 'Personal telephone number must only include numbers 0 to 9 and special characters such as spaces, brackets and +'
18
+ }),
19
+ personalMobile: Joi.string()
20
+ .empty('')
21
+ .min(PHONE_NUMBER_MIN)
22
+ .max(PHONE_NUMBER_MAX)
23
+ .pattern(PHONE_NUMBER_PATTERN)
24
+ .messages({
25
+ 'string.min': `Personal mobile phone number must be ${PHONE_NUMBER_MIN} characters or more`,
26
+ 'string.max': `Personal mobile phone number must be ${PHONE_NUMBER_MAX} characters or less`,
27
+ 'string.pattern.base': 'Personal mobile phone number must only include numbers 0 to 9 and special characters such as spaces, brackets and +'
28
+ })
29
+ })
30
+ .or('personalTelephone', 'personalMobile')
31
+ .messages({
32
+ 'object.missing': 'Enter at least one phone number'
33
+ })
@@ -0,0 +1,13 @@
1
+ import { personalNameSchema } from './personal-name-schema.js'
2
+ import { personalDobSchema } from './personal-dob-schema.js'
3
+ import { addressSchema } from '../address-schema.js'
4
+ import { personalEmailSchema } from './personal-email-schema.js'
5
+ import { personalPhoneSchema } from './personal-phone-schema.js'
6
+
7
+ export const personalSchemas = {
8
+ name: personalNameSchema,
9
+ dob: personalDobSchema,
10
+ address: addressSchema,
11
+ phone: personalPhoneSchema,
12
+ email: personalEmailSchema
13
+ }
@@ -1,7 +1,9 @@
1
1
  import { businessSchemas } from './business/business-schemas.js'
2
2
  import { customerSchemas } from './customer/customer-schemas.js'
3
+ import { personalSchemas } from './personal/personal-schemas.js'
3
4
 
4
5
  export const schemas = {
5
6
  business: businessSchemas,
6
- customer: customerSchemas
7
+ customer: customerSchemas,
8
+ personal: personalSchemas
7
9
  }