/ src / utils / phone-normalizer.js
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  };