Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/controllers/locations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
124 changes: 124 additions & 0 deletions src/utils/phones.js
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 10 additions & 1 deletion test/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,10 @@ async function execScript(script) {
}

beforeAll(async () => {
if (unitTestRun) {
return;
}

// reset the database state
await models.sequelize.query(`
DO $$
Expand Down Expand Up @@ -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();
Expand Down
61 changes: 61 additions & 0 deletions test/unit/phones.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});