/ src / utils / contact-validator.js
contact-validator.js
  1  /**
  2   * Contact Validation Utilities
  3   * Validates phone numbers, email addresses, and social URLs
  4   */
  5  
  6  import { promises as dns } from 'dns';
  7  import pkg from 'google-libphonenumber';
  8  
  9  const { PhoneNumberUtil, PhoneNumberType } = pkg;
 10  const phoneUtil = PhoneNumberUtil.getInstance();
 11  
 12  /**
 13   * Validate a phone number for a given country using google-libphonenumber
 14   * @param {string} number - Phone number (any format)
 15   * @param {string} countryCode - ISO 3166-1 alpha-2 country code (e.g. 'AU', 'US')
 16   * @returns {{ valid: boolean, reason: string|null }}
 17   */
 18  // Map non-standard country codes to ISO 3166-1 alpha-2 as used by libphonenumber
 19  const COUNTRY_CODE_ALIASES = { UK: 'GB', ENG: 'GB', SCO: 'GB', WAL: 'GB' };
 20  
 21  export function validatePhone(number, countryCode) {
 22    if (!number || typeof number !== 'string') {
 23      return { valid: false, reason: 'missing phone number' };
 24    }
 25  
 26    const cleaned = number.trim();
 27    if (!cleaned) return { valid: false, reason: 'empty phone number' };
 28  
 29    // Reject obvious placeholder/test numbers: 6+ consecutive identical digits
 30    // e.g. +15555555555 (all-5s), +10000000000, etc.
 31    const digitsOnly = cleaned.replace(/\D/g, '');
 32    if (/(\d)\1{5,}/.test(digitsOnly)) {
 33      return { valid: false, reason: `${cleaned} appears to be a placeholder (repeated digits)` };
 34    }
 35  
 36    // Reject NANP 555 subscriber exchange numbers (+1 AAAA 555-XXXX)
 37    // These are reserved/fictional in North America (US/CA/etc.)
 38    // 11 digits: 1 + 3-digit area code + 555 + 4 digits
 39    if (/^1\d{3}555\d{4}$/.test(digitsOnly)) {
 40      return { valid: false, reason: `${cleaned} is a fictitious NANP 555 number` };
 41    }
 42  
 43    // Normalize country code aliases (e.g. UK → GB)
 44  
 45    const normalizedCC =
 46      (countryCode && COUNTRY_CODE_ALIASES[countryCode]) || countryCode || undefined;
 47  
 48    try {
 49      const parsed = phoneUtil.parse(cleaned, normalizedCC);
 50      if (!phoneUtil.isValidNumber(parsed)) {
 51        return { valid: false, reason: `${cleaned} is not a valid phone number` };
 52      }
 53      if (normalizedCC && !phoneUtil.isValidNumberForRegion(parsed, normalizedCC)) {
 54        return {
 55          valid: false,
 56          reason: `${cleaned} is not a valid ${countryCode} phone number`,
 57        };
 58      }
 59      return { valid: true, reason: null };
 60    } catch (err) {
 61      return { valid: false, reason: `could not parse phone number: ${err.message}` };
 62    }
 63  }
 64  
 65  /**
 66   * Validate a phone is mobile (can receive SMS)
 67   * @param {string} number - Phone number in E.164 or local format
 68   * @param {string} countryCode - ISO country code
 69   * @returns {boolean}
 70   */
 71  export function isMobilePhone(number, countryCode) {
 72    const normalizedCC =
 73      (countryCode && COUNTRY_CODE_ALIASES[countryCode]) || countryCode || undefined;
 74    try {
 75      const parsed = phoneUtil.parse(number, normalizedCC);
 76      const type = phoneUtil.getNumberType(parsed);
 77      return type === PhoneNumberType.MOBILE || type === PhoneNumberType.FIXED_LINE_OR_MOBILE;
 78    } catch {
 79      return false;
 80    }
 81  }
 82  
 83  /**
 84   * Validate an email address format and optionally check domain MX records
 85   * DNS MX lookup is faster and more accurate than HTTP GET for email domains
 86   * @param {string} email - Email address
 87   * @param {Object} options
 88   * @param {boolean} options.checkMx - Whether to do DNS MX lookup (default: true)
 89   * @returns {Promise<{ valid: boolean, reason: string|null }>}
 90   */
 91  export async function validateEmail(email, { checkMx = true } = {}) {
 92    if (!email || typeof email !== 'string') {
 93      return { valid: false, reason: 'missing email' };
 94    }
 95  
 96    const cleaned = email.trim().toLowerCase();
 97  
 98    // Basic format check first
 99    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(cleaned)) {
100      return { valid: false, reason: `"${email}" is not a valid email format` };
101    }
102  
103    if (!checkMx) return { valid: true, reason: null };
104  
105    const domain = cleaned.split('@')[1];
106  
107    try {
108      const mx = await dns.resolveMx(domain);
109      if (!mx || mx.length === 0) {
110        return { valid: false, reason: `domain ${domain} has no MX records (cannot receive email)` };
111      }
112      return { valid: true, reason: null };
113    } catch {
114      return { valid: false, reason: `domain ${domain} not found or has no MX records` };
115    }
116  }
117  
118  /**
119   * Validate a social profile URL by doing an HTTP HEAD request
120   * @param {string} url - Social profile URL
121   * @returns {Promise<{ valid: boolean, status: number|null, reason: string|null }>}
122   */
123  export async function validateSocialUrl(url) {
124    if (!url || typeof url !== 'string') {
125      return { valid: false, status: null, reason: 'missing URL' };
126    }
127  
128    try {
129      const res = await fetch(url, {
130        method: 'HEAD',
131        redirect: 'follow',
132        signal: AbortSignal.timeout(8000),
133        headers: { 'User-Agent': 'Mozilla/5.0 (compatible; contact-validator/1.0)' },
134      });
135  
136      const ok = res.status < 400;
137      return {
138        valid: ok,
139        status: res.status,
140        reason: ok ? null : `HTTP ${res.status} for ${url}`,
141      };
142    } catch (err) {
143      return { valid: false, status: null, reason: `request failed: ${err.message}` };
144    }
145  }
146  
147  export default { validatePhone, isMobilePhone, validateEmail, validateSocialUrl };