From c120a2604c3651450ad58f136f8218d044ea7490 Mon Sep 17 00:00:00 2001 From: doobneek1 Date: Mon, 4 May 2026 15:22:16 -0700 Subject: [PATCH] Validate phone contact methods --- src/controllers/locations.js | 36 ++++++++-- src/utils/phones.js | 124 +++++++++++++++++++++++++++++++++++ test/setup.js | 11 +++- test/unit/phones.test.js | 61 +++++++++++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 src/utils/phones.js create mode 100644 test/unit/phones.test.js diff --git a/src/controllers/locations.js b/src/controllers/locations.js index 4e1aff5..ca09668 100644 --- a/src/controllers/locations.js +++ b/src/controllers/locations.js @@ -12,9 +12,13 @@ import geometry from '../utils/geometry'; import { parseBoolean } from '../utils/strings'; import { convertKeyValueArrayToObject } from '../utils/api-params'; import { NotFoundError, ValidationError } from '../utils/errors'; +import { normalizePhoneParams, validatePhoneForUse } from '../utils/phones'; const DEFAULT_MAX_LOCATIONS_RETURNED = 1000; const MAX_TAXONOMY_IDS = 200; +const PHONE_VALIDATION_FIELDS = ['number', 'type', 'extension']; + +const hasOwn = (object, property) => Object.prototype.hasOwnProperty.call(object, property); const isLocationClosed = (occasion, eventRelatedInfos, services) => { if (!occasion) { @@ -553,14 +557,21 @@ export default { description, metadata, } = req.body; - - const createdPhone = await createInstance(req.user, location.createPhone.bind(location), { + const rawPhoneParams = { number, extension, type, language, description, - }, { metadata }); + }; + validatePhoneForUse(rawPhoneParams); + + const createdPhone = await createInstance( + req.user, + location.createPhone.bind(location), + normalizePhoneParams(rawPhoneParams, type), + { metadata }, + ); res.status(201).send(createdPhone); } catch (err) { @@ -581,7 +592,24 @@ export default { const editableFields = ['number', 'extension', 'type', 'language', 'description']; const { metadata, ...updateParams } = req.body; - await updateInstance(req.user, phone, updateParams, { fields: editableFields, metadata }); + const shouldValidatePhone = PHONE_VALIDATION_FIELDS + .some(field => hasOwn(updateParams, field)); + + if (shouldValidatePhone) { + const existingPhone = phone.get({ plain: true }); + const candidatePhone = { + ...existingPhone, + ...updateParams, + }; + validatePhoneForUse(candidatePhone); + } + + await updateInstance( + req.user, + phone, + normalizePhoneParams(updateParams, phone.type), + { fields: editableFields, metadata }, + ); res.sendStatus(204); } catch (err) { diff --git a/src/utils/phones.js b/src/utils/phones.js new file mode 100644 index 0000000..e7b7423 --- /dev/null +++ b/src/utils/phones.js @@ -0,0 +1,124 @@ +import { ValidationError } from './errors'; + +export const PHONE_USE = { + PHONE: 'phone', + SMS: 'sms', + WHATSAPP: 'whatsapp', + FAX: 'fax', +}; + +const MIN_PHONE_DIGITS = 3; +const MAX_PHONE_DIGITS = 10; +const EXTENSION_PATTERN = /(?:ext\.?|extension|x)\s*[:#-]?\s*(\d+)\s*$/i; + +function hasOwn(object, property) { + return Object.prototype.hasOwnProperty.call(object, property); +} + +function hasExtension(extension) { + return extension !== undefined && + extension !== null && + `${extension}`.trim() !== ''; +} + +function splitInlineExtension(number) { + const rawNumber = `${number || ''}`.trim(); + const extensionMatch = rawNumber.match(EXTENSION_PATTERN); + + if (!extensionMatch) { + return { + numberPart: rawNumber, + extension: null, + }; + } + + return { + numberPart: rawNumber.slice(0, extensionMatch.index).trim(), + extension: extensionMatch[1], + }; +} + +export function normalizePhoneUse(type) { + const normalizedType = `${type || ''}`.toLowerCase().replace(/[\s_-]+/g, ''); + + if (normalizedType.includes('whatsapp')) { + return PHONE_USE.WHATSAPP; + } + + if (normalizedType.includes('sms') || normalizedType.includes('text')) { + return PHONE_USE.SMS; + } + + if (normalizedType.includes('fax')) { + return PHONE_USE.FAX; + } + + return PHONE_USE.PHONE; +} + +export function phoneCanHaveExtension(type) { + return normalizePhoneUse(type) === PHONE_USE.PHONE; +} + +export function normalizePhoneDigits(number) { + const digits = `${number || ''}`.replace(/\D/g, ''); + + if (digits.length === 11 && digits.startsWith('1')) { + return digits.slice(1); + } + + return digits; +} + +export function validatePhoneForUse(phone) { + const { numberPart, extension: inlineExtension } = splitInlineExtension(phone.number); + const digits = normalizePhoneDigits(numberPart); + const phoneUse = normalizePhoneUse(phone.type); + + if (digits.length < MIN_PHONE_DIGITS || digits.length > MAX_PHONE_DIGITS) { + throw new ValidationError('Phone number must contain 3 to 10 digits.'); + } + + if (phoneUse === PHONE_USE.WHATSAPP && digits.length !== MAX_PHONE_DIGITS) { + throw new ValidationError('WhatsApp numbers must contain exactly 10 digits.'); + } + + if ( + !phoneCanHaveExtension(phone.type) && + (hasExtension(phone.extension) || hasExtension(inlineExtension)) + ) { + throw new ValidationError('SMS, WhatsApp, and fax numbers cannot have extensions.'); + } +} + +export function normalizePhoneParams(params, fallbackType) { + const normalizedParams = { ...params }; + const type = hasOwn(normalizedParams, 'type') ? normalizedParams.type : fallbackType; + const canHaveExtension = phoneCanHaveExtension(type); + + if (hasOwn(normalizedParams, 'number')) { + const { + numberPart, + extension: inlineExtension, + } = splitInlineExtension(normalizedParams.number); + + normalizedParams.number = normalizePhoneDigits(numberPart); + + if ( + canHaveExtension && + !hasExtension(normalizedParams.extension) && + hasExtension(inlineExtension) + ) { + normalizedParams.extension = parseInt(inlineExtension, 10); + } + } + + if ( + hasOwn(normalizedParams, 'extension') && + hasExtension(normalizedParams.extension) + ) { + normalizedParams.extension = parseInt(normalizedParams.extension, 10); + } + + return normalizedParams; +} diff --git a/test/setup.js b/test/setup.js index 05f38c1..c8226d7 100644 --- a/test/setup.js +++ b/test/setup.js @@ -6,7 +6,8 @@ jest.setTimeout(10000); process.env.DATABASE_NAME = 'test'; process.env.DATABASE_LOGGING = 'false'; -const models = require('../src/models'); +const unitTestRun = process.argv.some(arg => arg.includes('test/unit')); +const models = unitTestRun ? null : require('../src/models'); async function execScript(script) { // run the migrations @@ -25,6 +26,10 @@ async function execScript(script) { } beforeAll(async () => { + if (unitTestRun) { + return; + } + // reset the database state await models.sequelize.query(` DO $$ @@ -53,6 +58,10 @@ beforeAll(async () => { await execScript('npx sequelize-cli db:migrate --name 20240607172205-age-filter'); }); afterAll(async () => { + if (unitTestRun) { + return; + } + // eslint-disable-next-line no-implied-eval await execScript('npx sequelize-cli db:migrate:undo --name 20240607172205-age-filter'); await models.sequelize.close(); diff --git a/test/unit/phones.test.js b/test/unit/phones.test.js new file mode 100644 index 0000000..219ff5b --- /dev/null +++ b/test/unit/phones.test.js @@ -0,0 +1,61 @@ +import { + normalizePhoneParams, + normalizePhoneUse, + phoneCanHaveExtension, + validatePhoneForUse, +} from '../../src/utils/phones'; + +describe('phone utilities', () => { + it('normalizes punctuation and a leading US country code', () => { + expect(normalizePhoneParams({ + number: '+1 (212) 555-1212', + extension: null, + type: 'Main Office', + })).toMatchObject({ + number: '2125551212', + extension: null, + }); + }); + + it('allows short phone and SMS numbers', () => { + expect(() => validatePhoneForUse({ number: '988', type: 'Hotline' })) + .not.toThrow(); + expect(() => validatePhoneForUse({ number: '12345', type: 'Text only' })) + .not.toThrow(); + }); + + it('requires WhatsApp numbers to be full ten digit numbers', () => { + expect(() => validatePhoneForUse({ number: '12345', type: 'WhatsApp' })) + .toThrow('WhatsApp numbers must contain exactly 10 digits.'); + }); + + it('blocks extensions on SMS, WhatsApp, and fax numbers', () => { + expect(() => validatePhoneForUse({ + number: '2125551212', + extension: 123, + type: 'SMS', + })).toThrow('SMS, WhatsApp, and fax numbers cannot have extensions.'); + + expect(() => validatePhoneForUse({ + number: '2125551212 x123', + type: 'Fax', + })).toThrow('SMS, WhatsApp, and fax numbers cannot have extensions.'); + }); + + it('parses inline extensions for regular phone numbers', () => { + expect(normalizePhoneParams({ + number: '(212) 555-1212 x123', + type: 'Main Office', + })).toMatchObject({ + number: '2125551212', + extension: 123, + }); + }); + + it('detects extensionless phone uses from free-form type text', () => { + expect(normalizePhoneUse('Text only')).toBe('sms'); + expect(normalizePhoneUse('WhatsApp')).toBe('whatsapp'); + expect(normalizePhoneUse('Fax')).toBe('fax'); + expect(phoneCanHaveExtension('Text only')).toBe(false); + }); +});