@defra-fish/business-rules-lib
Version:
Shared business rules for the rod licensing digital services
373 lines (342 loc) • 12.5 kB
JavaScript
import moment from 'moment'
/**
* Convert the string to use titlecase at each word boundary
*
* @param {Array<String>} exclusions Define a set of words which will not be converted
* @returns {function(*=): *|string}
*/
const toTitleCase = (exclusions = []) => {
const exclusionsRegex = exclusions.map(e => `${e}\\P{L}`).join('|')
const capitalisationExclusionLookahead = exclusions.length ? `(?!${exclusionsRegex})` : ''
const regex = new RegExp(`(?:^|\\P{L})${capitalisationExclusionLookahead}\\p{L}`, 'gu')
return value => value && value.toLowerCase().replace(regex, match => match.toUpperCase())
}
/**
* Capitalises special name prefixes - e.g. mcdonald => McDonald
*
* @param {Array<String>} prefixes Prefixes to be handled - e.g. ['Mc']
* @returns {function(*=): *|string}
*/
const capitaliseNamePrefixes = prefixes => {
const regex = new RegExp(`(?:^|[^\\p{L}])(${prefixes.join('|')})(\\p{L})`, 'gui')
const titleCaseFn = toTitleCase([])
return value => value && value.replace(regex, (match, g1, g2) => `${titleCaseFn(g1)}${g2.toUpperCase()}`)
}
const dateStringFormats = ['YYYY-MM-DD', 'YYYY-M-DD', 'YYYY-MM-D', 'YYYY-M-D']
/**
* Create a validator to check a contact's birth date
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.AnySchema}
*/
const createDateStringValidator = joi =>
joi.string().extend({
type: 'birthDate',
messages: {
'date.format': '{{#label}} must be in [YYYY-MM-DD] format',
'date.min': '{{#label}} date before minimum allowed',
'date.max': '{{#label}} must be less than or equal to "now"'
},
validate (value, helpers) {
const dateValue = moment(value, dateStringFormats, true)
if (!dateValue.isValid()) {
return { value, errors: helpers.error('date.format') }
}
return { value }
},
rules: {
birthDate: {
args: [
{
name: 'maxAge',
ref: false,
assert: value => typeof value === 'number' && !isNaN(value),
message: 'maxAge must be a number'
}
],
method (maxAge) {
return this.$_addRule({ name: 'birthDate', args: { maxAge } })
},
validate (value, helpers, args) {
const birthDate = moment(value, dateStringFormats, true)
if (!birthDate.isBefore(moment().startOf('day'))) {
return helpers.error('date.max')
}
if (birthDate.isBefore(moment().subtract(args.maxAge, 'years'))) {
return helpers.error('date.min')
}
return birthDate.format('YYYY-MM-DD')
}
}
}
})
const allowedUnicodeBlocks = '\\u0000-\\u024F'
const forbiddenCharsRegex = new RegExp(`[^${allowedUnicodeBlocks}\\s'’()-]`, 'u')
const forbiddenEmailRegex = new RegExp(`[^A-Za-z0-9\\s'’@._${allowedUnicodeBlocks}]`, 'u')
const forbiddenCharsNumbersRegex = new RegExp(`[^A-Za-z0-9\\s${allowedUnicodeBlocks}]`, 'u')
const forbiddenMobileRegex = /[^+()0-9\-.\s]+/g
/**
* Create a validator to check a contact's birth date
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.AnySchema}
*/
export const createBirthDateValidator = joi => createDateStringValidator(joi).trim().birthDate(120).required().example('2000-01-01')
/**
* Create a validator to check a contact's mobile phone number
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createEmailValidator = joi =>
checkCopyPasteValidator(joi, forbiddenEmailRegex).string().allowable().trim().email().max(100).lowercase().example('person@example.com')
export const mobilePhoneRegex = /^[+]*[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/
/**
* Create a validator to check a contact's mobile phone number
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createMobilePhoneValidator = joi =>
checkCopyPasteValidator(joi, forbiddenMobileRegex).string().allowable().trim().pattern(mobilePhoneRegex).example('person@example.com')
const maxPremises = 50
/**
* Create a validator to check a contact's address premises
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createPremisesValidator = joi =>
checkCopyPasteValidator(joi, forbiddenCharsNumbersRegex)
.string()
.allowable()
.trim()
.min(1)
.max(maxPremises)
.external(toTitleCase())
.required()
.example('Example House')
/**
* Create a validator to check a contact's address street
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createStreetValidator = joi => joi.string().trim().max(100).external(toTitleCase()).empty('').example('Example Street')
/**
* Create a validator to check a contact's address locality
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createLocalityValidator = joi => joi.string().trim().max(100).external(toTitleCase()).empty('').example('Near Sample')
/**
* Create a validator to check a contact's address town
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createTownValidator = joi =>
checkCopyPasteValidator(joi, forbiddenCharsRegex)
.string()
.allowable()
.trim()
.max(100)
.external(toTitleCase(['under', 'upon', 'in', 'on', 'cum', 'next', 'the', 'en', 'le', 'super']))
.required()
.example('Exampleton')
export const ukPostcodeRegex = /^([A-PR-UWYZ][0-9]{1,2}[A-HJKPSTUW]?|[A-PR-UWYZ][A-HK-Y][0-9]{1,2}[ABEHMNPRVWXY]?)\s{0,6}([0-9][A-Z]{2})$/i
export const overseasPostcodeRegex = /^([a-zA-Z0-9 ]{1,12})$/i
const maxPostcode = 12
/**
* Create a validator to check a contact's postcode
*
* Will automatically correct spacing for UK postcodes
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createUKPostcodeValidator = joi =>
checkCopyPasteValidator(joi, forbiddenCharsNumbersRegex)
.string()
.allowable()
.trim()
.min(1)
.max(maxPostcode)
.required()
.pattern(ukPostcodeRegex)
.replace(ukPostcodeRegex, '$1 $2')
.uppercase()
.example('AB12 3CD')
/**
* Create a validator to check/format overseas postcodes
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.StringSchema}
*/
export const createOverseasPostcodeValidator = joi =>
checkCopyPasteValidator(joi, forbiddenCharsNumbersRegex)
.string()
.allowable()
.trim()
.min(1)
.max(maxPostcode)
.uppercase()
.required()
.pattern(overseasPostcodeRegex)
const nameStringSubstitutions = [
/*
hyphen-minus U+002D (included here to remove surrounding spacing)
hyphen U+2010
non-breaking hyphen U+2011
figure dash U+2012
en dash U+2013
em dash U+2014
horizontal bar U+2015
hyphen bullet U+2043
swung dash U+2053
minus sign U+2212
*/
{ replacement: '\u002d', regex: '\\s*[\u002d\u2010\u2011\u2012\u2013\u2014\u2015\u2053\u2212]\\s*' },
/*
apostrophe U+0027 (included here to remove surrounding spacing)
backtick / grave accent U+0060
acute accent U+00B4
left single quotation mark U+2018
right single quotation mark U+2019
prime U+2032
reversed prime U+2035
hebrew punctuation geresh U+05F3
heavy single comma quotation mark ornament U+275C
latin small letter saltillo U+A78C
fullwidth apostrophe U+FF07
*/
{ replacement: '\u0027', regex: '\\s*[\u0027\u0060\u00B4\u2018\u2019\u2032\u2035\u05F3\u275C\uA78C\uFF07]\\s*' },
/*
comma U+002C
full stop U+002E
no-break space U+00A0
zero width space U+200B
narrow no-break space U+202F
word joiner U+2060
ideographic space U+3000
zero width no-break space U+FEFF
*/
{ replacement: ' ', regex: '\\s*[\u002c\u002e\u00a0\u200b\u202f\u2060\u3000\ufeff]\\s*' },
// brackets with improper spacing
{ replacement: '(', regex: '\\(\\s+' },
{ replacement: ')', regex: '\\s+\\)' }
]
const nameStringSubstitutionRegex = new RegExp(nameStringSubstitutions.map(s => `(${s.regex})`).join('|'), 'g')
/**
* Substitutes characters with their designated standard replacement
*
* @param {string} value The string in which to search for substitutions
* @returns {string} The substituted string
*/
export const standardiseName = value =>
String(value)
.replace(nameStringSubstitutionRegex, (match, ...groups) => {
// Find the first group which is matched (not returned as undefined) This will be the index of the matched rule.
const matchPos = groups.findIndex(g => g !== undefined)
return nameStringSubstitutions[matchPos].replacement
})
.replace(/\s+/g, ' ')
.trim()
// Regular expression component for validating a term within a name. Allows a alpha sequence or an alpha sequence surrounding by brackets
const nameTermRegex = '(?:\\p{L}+|\\(\\p{L}+\\))'
/**
* Regular expression used to validate firstname and lastname fields
* @type {RegExp}
*/
const nameStringRegex = new RegExp(`^${nameTermRegex}(?:[-'\\s]${nameTermRegex})*$`, 'u')
/**
* Create a custom validator extension to check names
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.AnySchema}
*/
const createNameStringValidator = (joi, { minimumLength }) =>
joi.string().extend({
type: 'name',
rules: {
allowable: {
validate (value, helpers) {
const alphaCharacters = value.replace(/\P{L}/gu, '')
if (alphaCharacters.length < minimumLength) {
return helpers.error('string.min')
}
value = standardiseName(value)
if (!nameStringRegex.test(value) || forbiddenCharsRegex.test(value)) {
return helpers.error('string.forbidden')
}
return value
}
}
},
messages: {
'string.min': `{{#label}} must contain at least ${minimumLength} alpha characters`,
'string.forbidden': '{{#label}} contains forbidden characters'
}
})
/**
* Create a validator to check a contact's first name
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @param {number} minimumLength allow the default minimum allowed length to be overridden
* @returns {Joi.AnySchema}
*/
export const createFirstNameValidator = (joi, { minimumLength = 2 } = {}) =>
createNameStringValidator(joi, { minimumLength }).allowable().max(100).trim().external(toTitleCase()).required().example('Fester')
/**
* Create a validator to check a contact's last name
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @param {number} minimumLength allow the default minimum allowed length to be overridden
* @returns {Joi.AnySchema}
*/
export const createLastNameValidator = (joi, { minimumLength = 2 } = {}) =>
createNameStringValidator(joi, { minimumLength })
.allowable()
.max(100)
.trim()
.external(toTitleCase(['van', 'de', 'der', 'den']))
.external(capitaliseNamePrefixes(['Mc', "O'"]))
.required()
.example('Tester')
/**
* Create a validator to check a valid national insurance number
*
* @param {Joi.Root} joi the joi validator used by the consuming project
* @returns {Joi.AnySchema}
*/
const ukNINORegEx =
/^([ABCEGHJ-PRSTW-Z][ABCEGHJ-NPRSTW-Z])(?<!(?:BG|GB|KN|NK|NT|TN|ZZ))\s?([0-9]{2})\s{0,3}([0-9]{2})\s{0,3}([0-9]{2})\s{0,3}([ABCD])$/
export const createNationalInsuranceNumberValidator = joi =>
checkCopyPasteValidator(joi, forbiddenCharsNumbersRegex)
.string()
.allowable()
.trim()
.uppercase()
.pattern(ukNINORegEx)
.replace(ukNINORegEx, '$1 $2 $3 $4 $5')
.required()
.description('A UK national insurance number')
.example('NH 12 34 56 A')
const checkCopyPasteValidator = (joi, forbiddenRegex) =>
joi.extend({
type: 'string',
base: joi.string(),
rules: {
allowable: {
validate (value, helpers) {
if (forbiddenRegex.test(value)) {
return helpers.error('string.forbidden')
}
return value
}
}
},
messages: {
'string.forbidden': '{{#label}} contains forbidden characters'
}
})