phone-normalizer.js
1 /** 2 * Phone Number Normalization Utility 3 * Converts phone numbers to E.164 format 4 */ 5 6 import { getCountryByCode } from '../config/countries.js'; 7 8 // Calling codes where a domestic trunk prefix 0 must be stripped. 9 // Maps calling code digits → expected subscriber length range [min, max]. 10 // Only includes countries where domestic numbers start with 0. 11 const TRUNK_ZERO_COUNTRIES = { 12 44: [10, 11], // UK: +44 7911 123456 (10-11 digits after CC) 13 61: [9, 9], // AU: +61 412 345 678 (9 digits after CC) 14 64: [8, 10], // NZ: +64 21 123 4567 (8-10 digits after CC) 15 91: [10, 10], // IN: +91 98765 43210 (10 digits after CC) 16 81: [9, 10], // JP: +81 90 1234 5678 (9-10 digits after CC) 17 49: [10, 12], // DE: +49 170 1234567 (10-12 digits after CC) 18 33: [9, 9], // FR: +33 6 12 34 56 78 (9 digits after CC) 19 39: [9, 11], // IT: +39 333 123 4567 (keeps 0 for landline — skip mobile) 20 34: [9, 9], // ES: +34 612 345 678 (9 digits, no trunk 0 but included for safety) 21 62: [9, 12], // ID: +62 812 3456 7890 (9-12 digits after CC) 22 31: [9, 9], // NL: +31 6 12345678 (9 digits after CC) 23 46: [9, 10], // SE: +46 70 123 45 67 (9-10 digits after CC) 24 43: [10, 12], // AT: +43 664 1234567 (10-12 digits after CC) 25 47: [8, 8], // NO: +47 412 34 567 (no trunk 0, but defensive) 26 45: [8, 8], // DK: +45 20 12 34 56 (no trunk 0, but defensive) 27 27: [9, 9], // ZA: +27 82 123 4567 (9 digits after CC) 28 353: [8, 9], // IE: +353 87 123 4567 (8-9 digits after CC) 29 82: [9, 11], // KR: +82 10 1234 5678 (9-11 digits after CC) 30 48: [9, 9], // PL: +48 512 345 678 (9 digits after CC) 31 52: [10, 10], // MX: +52 55 1234 5678 (10 digits after CC) 32 65: [8, 8], // SG: +65 9123 4567 (8 digits, no trunk 0) 33 41: [9, 9], // CH: +41 79 123 45 67 (9 digits after CC) 34 32: [8, 9], // BE: +32 470 12 34 56 (8-9 digits after CC) 35 }; 36 37 /** 38 * Strip domestic trunk prefix 0 that was incorrectly left after the calling code. 39 * e.g. +4401234567890 → +441234567890, +640274282748 → +6427428274 40 * 41 * @param {string} e164 - Phone number already in +digits form 42 * @returns {string} Corrected E.164 number 43 */ 44 function stripDomesticZero(e164) { 45 if (!e164 || !e164.startsWith('+')) return e164; 46 47 const digits = e164.slice(1); // remove leading + 48 49 // Try matching calling codes (longest first: 3-digit, 2-digit, 1-digit) 50 for (const len of [3, 2, 1]) { 51 const cc = digits.slice(0, len); 52 const range = TRUNK_ZERO_COUNTRIES[cc]; 53 if (!range) continue; 54 55 const subscriber = digits.slice(len); 56 // If subscriber starts with 0 and stripping it gives a valid length, strip it 57 if (subscriber.startsWith('0')) { 58 const stripped = subscriber.slice(1); 59 if (stripped.length >= range[0] && stripped.length <= range[1]) { 60 return `+${cc}${stripped}`; 61 } 62 } 63 // Matched a calling code but no trunk 0 — done 64 break; 65 } 66 67 return e164; 68 } 69 70 /** 71 * Normalize phone number to E.164 format 72 * E.164: +[country code][subscriber number] (digits only, no spaces/dashes/parentheses) 73 * Also strips domestic trunk prefix 0 when found after a known calling code. 74 * 75 * @param {string} phoneNumber - Phone number in any format 76 * @returns {string} E.164 formatted phone number 77 * @example 78 * normalizePhoneNumber("+1-609-619-7151") // Returns "+16096197151" 79 * normalizePhoneNumber("+44 01onal 282 771101") // Returns "+441282771101" 80 * normalizePhoneNumber("+64 027 428 2748") // Returns "+64274282748" 81 * normalizePhoneNumber("609-619-7151") // Returns "6096197151" (no country code) 82 */ 83 export function normalizePhoneNumber(phoneNumber) { 84 if (!phoneNumber || typeof phoneNumber !== 'string') { 85 return phoneNumber; 86 } 87 88 // Remove all characters except digits and plus sign 89 // Keep the leading + if present 90 let normalized = phoneNumber.replace(/[^\d+]/g, ''); 91 92 // Ensure only one + at the start 93 if (normalized.startsWith('+')) { 94 // Remove any other + signs after the first one 95 normalized = `+${normalized.slice(1).replace(/\+/g, '')}`; 96 } 97 98 // Strip domestic trunk prefix 0 after calling code 99 if (normalized.startsWith('+')) { 100 normalized = stripDomesticZero(normalized); 101 } 102 103 return normalized; 104 } 105 106 /** 107 * Normalize an array of phone number objects 108 * @param {Array} phoneNumbers - Array of {number, label} objects 109 * @returns {Array} Array with normalized phone numbers 110 */ 111 export function normalizePhoneNumbers(phoneNumbers) { 112 if (!Array.isArray(phoneNumbers)) { 113 return phoneNumbers; 114 } 115 116 return phoneNumbers.map(phone => { 117 if (typeof phone === 'string') { 118 return normalizePhoneNumber(phone); 119 } 120 121 if (phone && typeof phone === 'object' && phone.number) { 122 return { 123 ...phone, 124 number: normalizePhoneNumber(phone.number), 125 }; 126 } 127 128 return phone; 129 }); 130 } 131 132 /** 133 * Add country code to phone number if missing 134 * @param {string} phoneNumber - Phone number (may or may not have country code) 135 * @param {string} countryCode - ISO 3166-1 alpha-2 country code (e.g., "AU", "US") 136 * @returns {string} Phone number with country code in E.164 format 137 * @example 138 * addCountryCode("0412345678", "AU") // Returns "+61412345678" 139 * addCountryCode("+61412345678", "AU") // Returns "+61412345678" (already has country code) 140 * addCountryCode("(609) 619-7151", "US") // Returns "+16096197151" 141 */ 142 export function addCountryCode(phoneNumber, countryCode) { 143 if (!phoneNumber || typeof phoneNumber !== 'string') { 144 return phoneNumber; 145 } 146 147 // First normalize to remove formatting 148 let normalized = normalizePhoneNumber(phoneNumber); 149 150 // If already has country code (starts with +), return as-is 151 if (normalized.startsWith('+')) { 152 return normalized; 153 } 154 155 // Get country configuration 156 const country = countryCode ? getCountryByCode(countryCode.toUpperCase()) : null; 157 if (!country || !country.phoneFormat) { 158 // Unknown country code, return normalized number without country code 159 return normalized; 160 } 161 162 // Extract calling code from phoneFormat (e.g., "+61" -> "61") 163 const callingCode = country.phoneFormat.replace(/^\+/, ''); 164 165 // Remove leading domestic trunk prefix 0 (applies to most non-US/CA countries) 166 // e.g. AU 0412345678 → 412345678, UK 07911123456 → 7911123456 167 if (normalized.startsWith('0') && callingCode !== '1') { 168 normalized = normalized.slice(1); 169 } 170 171 // Add country code, then run through stripDomesticZero for safety 172 return stripDomesticZero(`+${callingCode}${normalized}`); 173 } 174 175 /** 176 * Check if a phone number looks fake/placeholder (555 numbers, repeated digits, etc.) 177 * @param {string} e164 - E.164 formatted phone number 178 * @returns {boolean} true if the number appears fake 179 */ 180 export function isFakeNumber(e164) { 181 if (!e164) return false; 182 const digits = e164.replace(/\D/g, ''); 183 184 // US/CA 555-01xx numbers (reserved fictional) and +1-555-xxx-xxxx (entire 555 area code) 185 if (/^1555\d{7}$/.test(digits)) return true; 186 if (/^1\d{3}55501\d{2}$/.test(digits)) return true; 187 188 // All same digit (e.g. +11111111111) 189 if (/^(\d)\1{6,}$/.test(digits)) return true; 190 191 // Sequential placeholder (1234567890) 192 if (digits.includes('1234567890') || digits.includes('0987654321')) return true; 193 194 return false; 195 } 196 197 /** 198 * Validate that a phone number is a plausible SMS destination. 199 * Mirrors the checks in src/outreach/sms.js detectBadPhoneNumber() so that 200 * bad numbers are caught at enrichment time rather than at send time. 201 * 202 * @param {string} phone - E.164 formatted phone number (e.g. +61412345678) 203 * @returns {string|null} Error description if invalid, null if OK to use for SMS 204 */ 205 export function isValidSmsNumber(phone) { 206 if (!phone || typeof phone !== 'string') return 'Empty or non-string phone number'; 207 208 const digits = phone.replace(/\D/g, ''); 209 210 // Must have at least 8 digits total (country code + subscriber) 211 if (digits.length < 8) return `Too short to be a real phone number (${phone})`; 212 213 // Must not exceed 15 digits (E.164 maximum) 214 if (digits.length > 15) return `Too many digits for E.164 (${phone})`; 215 216 // Must start with + followed by 1-9 (not +0) 217 if (phone.startsWith('+0')) return `Invalid E.164: starts with +0 (${phone})`; 218 219 // Must start with + if it has a country code — bare numbers without + are ambiguous 220 if (!phone.startsWith('+')) return `Missing country code (${phone})`; 221 222 // US/CA fictional 555 numbers 223 if (/^\+1555\d{7}$/.test(phone)) return `US 555 placeholder number (${phone})`; 224 225 // Repeated-digit patterns (e.g. +11111111111, +00000000) — same digit 7+ times 226 if (/^(\d)\1{6,}$/.test(digits)) return `Repeated-digit placeholder (${phone})`; 227 228 // Sequential placeholder (1234567890) 229 if (digits.includes('1234567890') || digits.includes('0987654321')) 230 return `Sequential placeholder digits (${phone})`; 231 232 // AU/NZ toll-free (1800, 1300, 1900) — cannot receive SMS 233 if (/^\+61(1800|1300|1900)/.test(phone)) return `AU toll-free number, not SMS-capable (${phone})`; 234 235 // US/CA toll-free 236 if (/^\+1(800|888|877|866|855|844|833)\d{7}$/.test(phone)) 237 return `US/CA toll-free number, not SMS-capable (${phone})`; 238 239 // Short code: after stripping known country codes, fewer than 7 digits remain 240 const withoutCountry = digits.replace(/^(1|61|44|64|91|33|49|81|82|52|62|27|353|48|31|39)/, ''); 241 if (withoutCountry.length > 0 && withoutCountry.length < 7) 242 return `Short code detected — not a mobile number (${phone})`; 243 244 return null; // Valid 245 } 246 247 export default { 248 normalizePhoneNumber, 249 normalizePhoneNumbers, 250 addCountryCode, 251 isFakeNumber, 252 isValidSmsNumber, 253 };