@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.
@@ -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
+ })
@@ -1,5 +1,7 @@
1
1
  import { businessSbiSchema } from './business-sbi-schema.js'
2
+ import { addressSchema } from '../address-schema.js'
2
3
 
3
4
  export const businessSchemas = {
4
- sbi: businessSbiSchema
5
+ sbi: businessSbiSchema,
6
+ address: addressSchema
5
7
  }
@@ -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
  }