@defra/fcp-sfd-frontend-engine 0.2.12 → 0.4.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.
- package/dist/index.cjs +416 -5
- package/dist/index.js +415 -5
- package/package.json +2 -2
- package/src/constants/index.js +16 -0
- package/src/constants/month-map.js +26 -0
- package/src/constants/patterns.js +1 -0
- package/src/constants/validation-fields-export.js +17 -0
- package/src/constants/validation-fields.js +14 -0
- package/src/index.js +1 -0
- package/src/presenters/base-presenter.js +299 -0
- package/src/presenters/presenters.js +19 -0
- package/src/schemas/address-schema.js +61 -0
- package/src/schemas/business/business-schemas.js +3 -1
- package/src/schemas/personal/personal-dob-schema.js +178 -0
- package/src/schemas/personal/personal-email-schema.js +20 -0
- package/src/schemas/personal/personal-name-schema.js +31 -0
- package/src/schemas/personal/personal-phone-schema.js +33 -0
- package/src/schemas/personal/personal-schemas.js +13 -0
- package/src/schemas/schemas.js +3 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base presenter for formatting data for display
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Formats the business name into back link text.
|
|
7
|
+
* If the business name is greater than 50 characters, it will be truncated with an ellipsis.
|
|
8
|
+
*/
|
|
9
|
+
const BACK_LINK_DISPLAY_MAX = 50
|
|
10
|
+
|
|
11
|
+
export const formatBackLink = (businessName) => {
|
|
12
|
+
if (businessName.length > BACK_LINK_DISPLAY_MAX) {
|
|
13
|
+
return `Back to ${businessName.slice(0, BACK_LINK_DISPLAY_MAX)}…`
|
|
14
|
+
}
|
|
15
|
+
return `Back to ${businessName}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The first time a user loads the phone numbers change page they won't have entered any data, so a payload
|
|
20
|
+
* or a changedNumber won't be present. If a user has a validation issue then we want to replay the payload data to them.
|
|
21
|
+
* We check if payload is not undefined because it could be a user has removed the 'mobile' number for example but
|
|
22
|
+
* incorrectly entered the telephone number so the payload for this would appear as an empty string.
|
|
23
|
+
*
|
|
24
|
+
* Payload is the priority to check and then after that if changedNumber is present then we display that value.
|
|
25
|
+
*
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
export const formatNumber = (payloadNumber, changedNumber, originalNumber) => {
|
|
29
|
+
if (payloadNumber !== undefined) {
|
|
30
|
+
return payloadNumber
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (changedNumber !== undefined) {
|
|
34
|
+
return changedNumber
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return originalNumber
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Identify the correct address to use from the DAL response.
|
|
42
|
+
*
|
|
43
|
+
* An address lookup is where a user enters a postcode and selects an address
|
|
44
|
+
* returned from an API. Manual input is when a user chooses to type the address in themselves.
|
|
45
|
+
*
|
|
46
|
+
* If the UPRN is populated, the user has selected an address from the lookup.
|
|
47
|
+
* UPRN is only returned by the lookup API; manual addresses will always have `uprn = null`.
|
|
48
|
+
*
|
|
49
|
+
* If both a building number range and a street are present, they are combined into one line so they display together.
|
|
50
|
+
*
|
|
51
|
+
* City, postcode and country are always appended to the final address array.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} address - The complete address object
|
|
54
|
+
*
|
|
55
|
+
* @returns {string[]} An array of address fields (either from lookup or manual)
|
|
56
|
+
*
|
|
57
|
+
* @private
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export const formatDisplayAddress = (address) => {
|
|
61
|
+
const { lookup, manual, postcode, country, city } = address
|
|
62
|
+
|
|
63
|
+
let addressLines = []
|
|
64
|
+
|
|
65
|
+
if (lookup.uprn) {
|
|
66
|
+
// If the uprn is populated then the user has selected an address from the lookup
|
|
67
|
+
const buildingAndStreet = [
|
|
68
|
+
lookup.buildingNumberRange,
|
|
69
|
+
lookup.street
|
|
70
|
+
].filter(Boolean).join(' ')
|
|
71
|
+
|
|
72
|
+
addressLines = [
|
|
73
|
+
lookup.pafOrganisationName,
|
|
74
|
+
lookup.flatName,
|
|
75
|
+
lookup.buildingName,
|
|
76
|
+
buildingAndStreet,
|
|
77
|
+
lookup.doubleDependentLocality,
|
|
78
|
+
lookup.dependentLocality,
|
|
79
|
+
city,
|
|
80
|
+
lookup.county
|
|
81
|
+
]
|
|
82
|
+
} else {
|
|
83
|
+
// Otherwise the user manually entered the address
|
|
84
|
+
addressLines = [
|
|
85
|
+
manual.line1,
|
|
86
|
+
manual.line2,
|
|
87
|
+
manual.line3,
|
|
88
|
+
city,
|
|
89
|
+
manual.line4, // County
|
|
90
|
+
manual.line5
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
...addressLines.filter(Boolean),
|
|
96
|
+
postcode,
|
|
97
|
+
country
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Combines address parts into a single line, joining non-empty values with commas.
|
|
103
|
+
*
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
const buildAddressLine = (parts) => {
|
|
107
|
+
return parts.filter(Boolean).join(', ') || null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Combines building range and street into a single line, joining with a space.
|
|
112
|
+
*
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
const buildStreetLine = (buildingRange, street) => {
|
|
116
|
+
return [buildingRange, street].filter(Boolean).join(' ') || null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Formats a lookup address (from address API) into a consistent flat structure.
|
|
121
|
+
*
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
const formatLookupAddress = (lookup, city, country, postcode) => ({
|
|
125
|
+
address1: buildAddressLine([lookup.pafOrganisationName, lookup.flatName, lookup.buildingName]),
|
|
126
|
+
address2: buildStreetLine(lookup.buildingNumberRange, lookup.street),
|
|
127
|
+
address3: buildAddressLine([lookup.doubleDependentLocality, lookup.dependentLocality]),
|
|
128
|
+
county: lookup.county ?? null,
|
|
129
|
+
city: city ?? null,
|
|
130
|
+
country: country ?? null,
|
|
131
|
+
postcode: postcode ?? null
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Formats a manually entered address into a consistent flat structure.
|
|
136
|
+
*
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
const formatManualAddress = (manual, city, country, postcode) => ({
|
|
140
|
+
address1: manual.line1 ?? null,
|
|
141
|
+
address2: manual.line2 ?? null,
|
|
142
|
+
address3: manual.line3 ?? null,
|
|
143
|
+
city: city ?? null,
|
|
144
|
+
county: manual.line4 ?? null,
|
|
145
|
+
country: country ?? null,
|
|
146
|
+
postcode: postcode ?? null
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Formats an original address fetched from the DAL into a flattened structure
|
|
151
|
+
* suitable for display or form population on the `address-enter` pages.
|
|
152
|
+
*
|
|
153
|
+
* If the address contains a UPRN, it is treated as a lookup address;
|
|
154
|
+
* otherwise, it is treated as a manually entered address.
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} originalAddress - The full address object from the DAL
|
|
157
|
+
*
|
|
158
|
+
* @returns {Object} A flattened address object with consistent keys
|
|
159
|
+
*/
|
|
160
|
+
export const formatOriginalAddress = (originalAddress) => {
|
|
161
|
+
const { lookup, manual, city, country, postcode } = originalAddress
|
|
162
|
+
return lookup.uprn
|
|
163
|
+
? formatLookupAddress(lookup, city, country, postcode)
|
|
164
|
+
: formatManualAddress(manual, city, country, postcode)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Formats a changed address object into a consistent structure for form display.
|
|
169
|
+
*
|
|
170
|
+
* If the address includes a UPRN, it indicates the user selected it from an address lookup.
|
|
171
|
+
* In that case, the lookup fields are combined and mapped into the manual address format used by the form.
|
|
172
|
+
*
|
|
173
|
+
* If the address does not include a UPRN, it is assumed to be manually entered and returned as-is.
|
|
174
|
+
*
|
|
175
|
+
* @param {Object} changeBusinessAddress - The changed address object to format
|
|
176
|
+
*
|
|
177
|
+
* @returns {Object} A formatted address object with fields `address1`, `address2`, `address3`,
|
|
178
|
+
* `city`, `county`, `country`, and `postcode`.
|
|
179
|
+
*/
|
|
180
|
+
export const formatChangedAddress = (changeBusinessAddress) => {
|
|
181
|
+
if (!changeBusinessAddress.uprn) {
|
|
182
|
+
// manual address (no lookup used)
|
|
183
|
+
return changeBusinessAddress
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const {
|
|
187
|
+
pafOrganisationName,
|
|
188
|
+
flatName,
|
|
189
|
+
buildingName,
|
|
190
|
+
buildingNumberRange,
|
|
191
|
+
street,
|
|
192
|
+
doubleDependentLocality,
|
|
193
|
+
dependentLocality,
|
|
194
|
+
city,
|
|
195
|
+
county,
|
|
196
|
+
country,
|
|
197
|
+
postcode
|
|
198
|
+
} = changeBusinessAddress
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
address1: buildAddressLine([pafOrganisationName, flatName, buildingName]),
|
|
202
|
+
address2: buildStreetLine(buildingNumberRange, street),
|
|
203
|
+
address3: buildAddressLine([doubleDependentLocality, dependentLocality]),
|
|
204
|
+
city: city ?? null,
|
|
205
|
+
county: county ?? null,
|
|
206
|
+
country: country ?? null,
|
|
207
|
+
postcode: postcode ?? null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Formats a list of address objects for display in a dropdown menu.
|
|
213
|
+
*
|
|
214
|
+
* Each address object is transformed into an option with:
|
|
215
|
+
* - `value`: a concatenation of the address UPRN and displayAddress
|
|
216
|
+
* - `text`: the formatted display address string
|
|
217
|
+
* - `selected`: true only if it matches the previously picked address
|
|
218
|
+
*
|
|
219
|
+
* A summary option is prepended to the start of the list, showing how many
|
|
220
|
+
* addresses were found. This summary option is selected by default unless
|
|
221
|
+
* a previously picked address exists.
|
|
222
|
+
*
|
|
223
|
+
* This function is shared across presenters that display address selection
|
|
224
|
+
* lists (e.g. personal or business address flows).
|
|
225
|
+
*
|
|
226
|
+
* Note: The `value` combines `uprn` and `displayAddress` to ensure uniqueness.
|
|
227
|
+
* Some addresses (for example, postcode LL55 2NF) have been observed to share
|
|
228
|
+
* the same UPRN, which caused incorrect selections when UPRN alone was used.
|
|
229
|
+
* Concatenating both fields guarantees each dropdown option has a unique value.
|
|
230
|
+
*
|
|
231
|
+
* @param {Array<Object>} addresses - List of address objects with `uprn` and `displayAddress` properties
|
|
232
|
+
* @param {Object} [previouslyPickedAddress] - Optional object representing the address previously selected by the user
|
|
233
|
+
*
|
|
234
|
+
* @returns {Array<Object>} Array of formatted address options ready for display
|
|
235
|
+
*/
|
|
236
|
+
export const formatDisplayAddresses = (addresses, previouslyPickedAddress) => {
|
|
237
|
+
const displayAddresses = addresses.map(address => ({
|
|
238
|
+
value: `${address.uprn}${address.displayAddress}`,
|
|
239
|
+
text: address.displayAddress,
|
|
240
|
+
selected:
|
|
241
|
+
previouslyPickedAddress?.uprn === address.uprn &&
|
|
242
|
+
previouslyPickedAddress?.displayAddress === address.displayAddress
|
|
243
|
+
}))
|
|
244
|
+
|
|
245
|
+
// Check if any address is already selected
|
|
246
|
+
const hasSelectedAddress = displayAddresses.some(addr => addr.selected)
|
|
247
|
+
|
|
248
|
+
// Add a display summary option to the beginning of the list
|
|
249
|
+
// e.g. "18 addresses found"
|
|
250
|
+
const text = addresses.length === 1 ? '1 address found' : `${addresses.length} addresses found`
|
|
251
|
+
|
|
252
|
+
displayAddresses.unshift({
|
|
253
|
+
value: 'display',
|
|
254
|
+
text,
|
|
255
|
+
selected: !hasSelectedAddress
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return displayAddresses
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Shared helper used by base presenters to sort validation errors so they
|
|
263
|
+
* appear in the same order as the sections and fields shown on a Fix List page.
|
|
264
|
+
*
|
|
265
|
+
* Fix List pages are built dynamically depending on:
|
|
266
|
+
* - which sections need fixing, and
|
|
267
|
+
* - which field the user originally selected.
|
|
268
|
+
*
|
|
269
|
+
* Because of that, we can’t rely on the order of the validation object.
|
|
270
|
+
* We need to deliberately sort the errors so they match:
|
|
271
|
+
* 1. The order the sections appear on the page, and
|
|
272
|
+
* 2. The logical order of fields within each section.
|
|
273
|
+
*
|
|
274
|
+
* This function keeps that logic in one reusable place so it can be used
|
|
275
|
+
* by Business, Personal, or any future Fix List presenter.
|
|
276
|
+
*
|
|
277
|
+
* It returns the errors as an array, already arranged in the correct
|
|
278
|
+
* display order for the UI.
|
|
279
|
+
*/
|
|
280
|
+
export const sortErrorsBySectionOrder = (errors, orderedSectionsToFix, SECTION_FIELD_ORDER) => {
|
|
281
|
+
const sortedErrors = []
|
|
282
|
+
|
|
283
|
+
for (const section of orderedSectionsToFix) {
|
|
284
|
+
// A section (i.e 'address') can have multiple fields (i.e 'line1', 'line2', 'line3')
|
|
285
|
+
const fieldsInSection = SECTION_FIELD_ORDER[section] || []
|
|
286
|
+
|
|
287
|
+
for (const field of fieldsInSection) {
|
|
288
|
+
// If there's an error for this field, add it to the sorted list with the error details
|
|
289
|
+
if (errors[field]) {
|
|
290
|
+
sortedErrors.push({
|
|
291
|
+
field,
|
|
292
|
+
...errors[field]
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return sortedErrors
|
|
299
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatBackLink,
|
|
3
|
+
formatNumber,
|
|
4
|
+
formatDisplayAddress,
|
|
5
|
+
formatOriginalAddress,
|
|
6
|
+
formatChangedAddress,
|
|
7
|
+
formatDisplayAddresses,
|
|
8
|
+
sortErrorsBySectionOrder
|
|
9
|
+
} from './base-presenter.js'
|
|
10
|
+
|
|
11
|
+
export const presenters = {
|
|
12
|
+
formatBackLink,
|
|
13
|
+
formatNumber,
|
|
14
|
+
formatDisplayAddress,
|
|
15
|
+
formatOriginalAddress,
|
|
16
|
+
formatChangedAddress,
|
|
17
|
+
formatDisplayAddresses,
|
|
18
|
+
sortErrorsBySectionOrder
|
|
19
|
+
}
|
|
@@ -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,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
|
+
}
|
package/src/schemas/schemas.js
CHANGED
|
@@ -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
|
}
|