timezone-detector.js
1 /** 2 * Timezone Detection Utility 3 * Maps city + country code to IANA timezone identifiers for TCPA compliance 4 */ 5 6 import Logger from './logger.js'; 7 8 const logger = new Logger('TimezoneDetector'); 9 10 /** 11 * Country-level timezone mappings 12 * For countries with single timezone or default timezone 13 */ 14 const COUNTRY_TIMEZONES = { 15 // North America 16 US: 'America/New_York', // Default to Eastern (most populous) 17 CA: 'America/Toronto', // Default to Eastern Canada 18 MX: 'America/Mexico_City', 19 20 // Europe 21 GB: 'Europe/London', 22 UK: 'Europe/London', // Alias: DB stores UK sites as country_code='UK' not 'GB' 23 IE: 'Europe/Dublin', 24 FR: 'Europe/Paris', 25 DE: 'Europe/Berlin', 26 IT: 'Europe/Rome', 27 ES: 'Europe/Madrid', 28 PT: 'Europe/Lisbon', 29 NL: 'Europe/Amsterdam', 30 BE: 'Europe/Brussels', 31 SE: 'Europe/Stockholm', 32 NO: 'Europe/Oslo', 33 DK: 'Europe/Copenhagen', 34 FI: 'Europe/Helsinki', 35 PL: 'Europe/Warsaw', 36 CZ: 'Europe/Prague', 37 AT: 'Europe/Vienna', 38 CH: 'Europe/Zurich', 39 GR: 'Europe/Athens', 40 41 // Asia-Pacific 42 AU: 'Australia/Sydney', // Default to NSW (most populous) 43 NZ: 'Pacific/Auckland', 44 JP: 'Asia/Tokyo', 45 CN: 'Asia/Shanghai', 46 IN: 'Asia/Kolkata', 47 SG: 'Asia/Singapore', 48 HK: 'Asia/Hong_Kong', 49 TW: 'Asia/Taipei', 50 KR: 'Asia/Seoul', 51 TH: 'Asia/Bangkok', 52 MY: 'Asia/Kuala_Lumpur', 53 PH: 'Asia/Manila', 54 ID: 'Asia/Jakarta', 55 VN: 'Asia/Ho_Chi_Minh', 56 57 // Middle East 58 AE: 'Asia/Dubai', 59 SA: 'Asia/Riyadh', 60 IL: 'Asia/Jerusalem', 61 62 // South America 63 BR: 'America/Sao_Paulo', 64 AR: 'America/Argentina/Buenos_Aires', 65 CL: 'America/Santiago', 66 CO: 'America/Bogota', 67 PE: 'America/Lima', 68 69 // Africa 70 ZA: 'Africa/Johannesburg', 71 EG: 'Africa/Cairo', 72 NG: 'Africa/Lagos', 73 KE: 'Africa/Nairobi', 74 }; 75 76 /** 77 * City-specific timezone mappings 78 * For cities in countries with multiple timezones 79 * Format: "city_name|country_code" -> IANA timezone 80 */ 81 const CITY_TIMEZONES = { 82 // United States 83 'new york|US': 'America/New_York', 84 'boston|US': 'America/New_York', 85 'philadelphia|US': 'America/New_York', 86 'atlanta|US': 'America/New_York', 87 'miami|US': 'America/New_York', 88 'washington|US': 'America/New_York', 89 'chicago|US': 'America/Chicago', 90 'dallas|US': 'America/Chicago', 91 'houston|US': 'America/Chicago', 92 'austin|US': 'America/Chicago', 93 'denver|US': 'America/Denver', 94 'phoenix|US': 'America/Phoenix', // Arizona doesn't observe DST 95 'los angeles|US': 'America/Los_Angeles', 96 'san francisco|US': 'America/Los_Angeles', 97 'seattle|US': 'America/Los_Angeles', 98 'portland|US': 'America/Los_Angeles', 99 'las vegas|US': 'America/Los_Angeles', 100 'san diego|US': 'America/Los_Angeles', 101 'anchorage|US': 'America/Anchorage', 102 'honolulu|US': 'Pacific/Honolulu', 103 104 // Canada 105 'toronto|CA': 'America/Toronto', 106 'montreal|CA': 'America/Toronto', 107 'ottawa|CA': 'America/Toronto', 108 'winnipeg|CA': 'America/Winnipeg', 109 'regina|CA': 'America/Regina', // Saskatchewan doesn't observe DST 110 'calgary|CA': 'America/Edmonton', 111 'edmonton|CA': 'America/Edmonton', 112 'vancouver|CA': 'America/Vancouver', 113 'victoria|CA': 'America/Vancouver', 114 "st. john's|CA": 'America/St_Johns', // Newfoundland (UTC-3:30) 115 116 // Australia 117 'sydney|AU': 'Australia/Sydney', 118 'melbourne|AU': 'Australia/Melbourne', 119 'brisbane|AU': 'Australia/Brisbane', 120 'perth|AU': 'Australia/Perth', 121 'adelaide|AU': 'Australia/Adelaide', 122 'hobart|AU': 'Australia/Hobart', 123 'darwin|AU': 'Australia/Darwin', 124 'canberra|AU': 'Australia/Sydney', 125 126 // Mexico 127 'mexico city|MX': 'America/Mexico_City', 128 'cancun|MX': 'America/Cancun', 129 'tijuana|MX': 'America/Tijuana', 130 'chihuahua|MX': 'America/Chihuahua', 131 132 // Brazil 133 'sao paulo|BR': 'America/Sao_Paulo', 134 'rio de janeiro|BR': 'America/Sao_Paulo', 135 'brasilia|BR': 'America/Sao_Paulo', 136 'manaus|BR': 'America/Manaus', 137 138 // Russia 139 'moscow|RU': 'Europe/Moscow', 140 'st petersburg|RU': 'Europe/Moscow', 141 'novosibirsk|RU': 'Asia/Novosibirsk', 142 'vladivostok|RU': 'Asia/Vladivostok', 143 }; 144 145 /** 146 * Detect timezone from city and country code 147 * @param {string|null} city - City name 148 * @param {string|null} countryCode - ISO 3166-1 alpha-2 country code 149 * @returns {string} - IANA timezone identifier 150 */ 151 export function detectTimezone(city, countryCode) { 152 // Default to US Eastern if no location data 153 if (!city && !countryCode) { 154 logger.warn('No location data provided, defaulting to America/New_York'); 155 return 'America/New_York'; 156 } 157 158 // Try city-specific lookup first 159 if (city && countryCode) { 160 const cityKey = `${city.toLowerCase()}|${countryCode.toUpperCase()}`; 161 // eslint-disable-next-line security/detect-object-injection -- Safe: key built from location data, not user input 162 if (CITY_TIMEZONES[cityKey]) { 163 logger.info( 164 `Detected timezone from city: ${city}, ${countryCode} → ${CITY_TIMEZONES[cityKey]}` // eslint-disable-line security/detect-object-injection 165 ); 166 return CITY_TIMEZONES[cityKey]; // eslint-disable-line security/detect-object-injection 167 } 168 169 // Try partial city name match (e.g., "San Francisco Bay Area" → "san francisco") 170 for (const [key, timezone] of Object.entries(CITY_TIMEZONES)) { 171 const [knownCity, knownCountry] = key.split('|'); 172 if (countryCode.toUpperCase() === knownCountry && city.toLowerCase().includes(knownCity)) { 173 logger.info( 174 `Detected timezone from partial city match: ${city}, ${countryCode} → ${timezone}` 175 ); 176 return timezone; 177 } 178 } 179 } 180 181 // Fall back to country-level timezone 182 if (countryCode && COUNTRY_TIMEZONES[countryCode.toUpperCase()]) { 183 const timezone = COUNTRY_TIMEZONES[countryCode.toUpperCase()]; 184 logger.info(`Detected timezone from country: ${countryCode} → ${timezone}`); 185 return timezone; 186 } 187 188 // Default to US Eastern if no match found 189 logger.warn( 190 `No timezone mapping found for ${city || 'unknown'}, ${countryCode || 'unknown'} - defaulting to America/New_York` 191 ); 192 return 'America/New_York'; 193 } 194 195 // ─── Phone area code → timezone (US/CA only) ──────────────────────────────── 196 // Fallback for TCPA compliance when city is unknown. 197 // Maps US/CA area codes to IANA timezones. Only the most common codes are listed; 198 // unlisted codes fall back to country-level default. 199 const US_AREA_CODE_TIMEZONES = { 200 // Eastern (America/New_York) 201 '201': 'America/New_York', '202': 'America/New_York', '203': 'America/New_York', 202 '207': 'America/New_York', '212': 'America/New_York', '215': 'America/New_York', 203 '216': 'America/New_York', '224': 'America/New_York', '225': 'America/New_York', 204 '229': 'America/New_York', '231': 'America/New_York', '234': 'America/New_York', 205 '239': 'America/New_York', '240': 'America/New_York', '248': 'America/New_York', 206 '267': 'America/New_York', '269': 'America/New_York', '276': 'America/New_York', 207 '281': 'America/New_York', '301': 'America/New_York', '302': 'America/New_York', 208 '304': 'America/New_York', '305': 'America/New_York', '312': 'America/New_York', 209 '313': 'America/New_York', '315': 'America/New_York', '316': 'America/New_York', 210 '321': 'America/New_York', '330': 'America/New_York', '332': 'America/New_York', 211 '334': 'America/New_York', '336': 'America/New_York', '339': 'America/New_York', 212 '347': 'America/New_York', '351': 'America/New_York', '352': 'America/New_York', 213 '360': 'America/New_York', '386': 'America/New_York', '401': 'America/New_York', 214 '404': 'America/New_York', '407': 'America/New_York', '410': 'America/New_York', 215 '412': 'America/New_York', '413': 'America/New_York', '414': 'America/New_York', 216 '419': 'America/New_York', '440': 'America/New_York', '443': 'America/New_York', 217 '475': 'America/New_York', '478': 'America/New_York', '484': 'America/New_York', 218 '502': 'America/New_York', '508': 'America/New_York', '513': 'America/New_York', 219 '516': 'America/New_York', '517': 'America/New_York', '518': 'America/New_York', 220 '540': 'America/New_York', '551': 'America/New_York', '561': 'America/New_York', 221 '567': 'America/New_York', '570': 'America/New_York', '571': 'America/New_York', 222 '574': 'America/New_York', '585': 'America/New_York', '586': 'America/New_York', 223 '601': 'America/New_York', '603': 'America/New_York', '607': 'America/New_York', 224 '609': 'America/New_York', '610': 'America/New_York', '614': 'America/New_York', 225 '616': 'America/New_York', '617': 'America/New_York', '631': 'America/New_York', 226 '646': 'America/New_York', '667': 'America/New_York', '678': 'America/New_York', 227 '706': 'America/New_York', '716': 'America/New_York', '717': 'America/New_York', 228 '718': 'America/New_York', '724': 'America/New_York', '727': 'America/New_York', 229 '732': 'America/New_York', '740': 'America/New_York', '754': 'America/New_York', 230 '757': 'America/New_York', '770': 'America/New_York', '772': 'America/New_York', 231 '774': 'America/New_York', '781': 'America/New_York', '786': 'America/New_York', 232 '802': 'America/New_York', '803': 'America/New_York', '804': 'America/New_York', 233 '813': 'America/New_York', '828': 'America/New_York', '843': 'America/New_York', 234 '845': 'America/New_York', '848': 'America/New_York', '856': 'America/New_York', 235 '857': 'America/New_York', '860': 'America/New_York', '862': 'America/New_York', 236 '863': 'America/New_York', '864': 'America/New_York', '878': 'America/New_York', 237 '904': 'America/New_York', '908': 'America/New_York', '910': 'America/New_York', 238 '912': 'America/New_York', '914': 'America/New_York', '917': 'America/New_York', 239 '919': 'America/New_York', '929': 'America/New_York', '931': 'America/New_York', 240 '937': 'America/New_York', '941': 'America/New_York', '954': 'America/New_York', 241 '973': 'America/New_York', '978': 'America/New_York', '980': 'America/New_York', 242 243 // Central (America/Chicago) 244 '205': 'America/Chicago', '210': 'America/Chicago', '214': 'America/Chicago', 245 '217': 'America/Chicago', '218': 'America/Chicago', '219': 'America/Chicago', 246 '228': 'America/Chicago', '251': 'America/Chicago', '252': 'America/Chicago', 247 '254': 'America/Chicago', '256': 'America/Chicago', '262': 'America/Chicago', 248 '270': 'America/Chicago', '314': 'America/Chicago', '317': 'America/Chicago', 249 '318': 'America/Chicago', '319': 'America/Chicago', '320': 'America/Chicago', 250 '325': 'America/Chicago', '331': 'America/Chicago', '346': 'America/Chicago', 251 '361': 'America/Chicago', '402': 'America/Chicago', '405': 'America/Chicago', 252 '409': 'America/Chicago', '417': 'America/Chicago', '430': 'America/Chicago', 253 '432': 'America/Chicago', '469': 'America/Chicago', '470': 'America/Chicago', 254 '479': 'America/Chicago', '501': 'America/Chicago', '504': 'America/Chicago', 255 '507': 'America/Chicago', '512': 'America/Chicago', '515': 'America/Chicago', 256 '563': 'America/Chicago', '573': 'America/Chicago', '580': 'America/Chicago', 257 '608': 'America/Chicago', '612': 'America/Chicago', '615': 'America/Chicago', 258 '618': 'America/Chicago', '630': 'America/Chicago', '636': 'America/Chicago', 259 '641': 'America/Chicago', '651': 'America/Chicago', '660': 'America/Chicago', 260 '682': 'America/Chicago', '701': 'America/Chicago', '708': 'America/Chicago', 261 '712': 'America/Chicago', '713': 'America/Chicago', '715': 'America/Chicago', 262 '731': 'America/Chicago', '737': 'America/Chicago', '763': 'America/Chicago', 263 '769': 'America/Chicago', '773': 'America/Chicago', '779': 'America/Chicago', 264 '806': 'America/Chicago', '815': 'America/Chicago', '816': 'America/Chicago', 265 '817': 'America/Chicago', '830': 'America/Chicago', '832': 'America/Chicago', 266 '847': 'America/Chicago', '850': 'America/Chicago', '870': 'America/Chicago', 267 '901': 'America/Chicago', '903': 'America/Chicago', '913': 'America/Chicago', 268 '918': 'America/Chicago', '920': 'America/Chicago', '936': 'America/Chicago', 269 '940': 'America/Chicago', '947': 'America/Chicago', '952': 'America/Chicago', 270 '956': 'America/Chicago', '972': 'America/Chicago', '979': 'America/Chicago', 271 272 // Mountain (America/Denver) 273 '303': 'America/Denver', '307': 'America/Denver', '385': 'America/Denver', 274 '406': 'America/Denver', '435': 'America/Denver', '505': 'America/Denver', 275 '575': 'America/Denver', '719': 'America/Denver', '720': 'America/Denver', 276 '801': 'America/Denver', '970': 'America/Denver', 277 278 // Arizona (America/Phoenix — no DST) 279 '480': 'America/Phoenix', '520': 'America/Phoenix', '602': 'America/Phoenix', 280 '623': 'America/Phoenix', '928': 'America/Phoenix', 281 282 // Pacific (America/Los_Angeles) 283 '206': 'America/Los_Angeles', '208': 'America/Los_Angeles', '209': 'America/Los_Angeles', 284 '213': 'America/Los_Angeles', '253': 'America/Los_Angeles', '310': 'America/Los_Angeles', 285 '323': 'America/Los_Angeles', '341': 'America/Los_Angeles', '350': 'America/Los_Angeles', 286 '369': 'America/Los_Angeles', '408': 'America/Los_Angeles', '415': 'America/Los_Angeles', 287 '424': 'America/Los_Angeles', '425': 'America/Los_Angeles', '442': 'America/Los_Angeles', 288 '458': 'America/Los_Angeles', '503': 'America/Los_Angeles', '509': 'America/Los_Angeles', 289 '510': 'America/Los_Angeles', '530': 'America/Los_Angeles', '541': 'America/Los_Angeles', 290 '559': 'America/Los_Angeles', '562': 'America/Los_Angeles', '619': 'America/Los_Angeles', 291 '626': 'America/Los_Angeles', '628': 'America/Los_Angeles', '650': 'America/Los_Angeles', 292 '657': 'America/Los_Angeles', '661': 'America/Los_Angeles', '669': 'America/Los_Angeles', 293 '702': 'America/Los_Angeles', '707': 'America/Los_Angeles', '714': 'America/Los_Angeles', 294 '747': 'America/Los_Angeles', '760': 'America/Los_Angeles', '775': 'America/Los_Angeles', 295 '805': 'America/Los_Angeles', '818': 'America/Los_Angeles', '831': 'America/Los_Angeles', 296 '858': 'America/Los_Angeles', '909': 'America/Los_Angeles', '916': 'America/Los_Angeles', 297 '925': 'America/Los_Angeles', '949': 'America/Los_Angeles', '951': 'America/Los_Angeles', 298 '971': 'America/Los_Angeles', 299 300 // Alaska (America/Anchorage) 301 '907': 'America/Anchorage', 302 303 // Hawaii (Pacific/Honolulu) 304 '808': 'Pacific/Honolulu', 305 }; 306 307 // Canadian area codes (major ones) 308 const CA_AREA_CODE_TIMEZONES = { 309 // Eastern (America/Toronto) 310 '226': 'America/Toronto', '249': 'America/Toronto', '289': 'America/Toronto', 311 '343': 'America/Toronto', '365': 'America/Toronto', '416': 'America/Toronto', 312 '437': 'America/Toronto', '519': 'America/Toronto', '548': 'America/Toronto', 313 '613': 'America/Toronto', '647': 'America/Toronto', '705': 'America/Toronto', 314 '807': 'America/Toronto', '905': 'America/Toronto', 315 316 // Quebec (America/Toronto — same offset as Eastern) 317 '418': 'America/Toronto', '438': 'America/Toronto', '450': 'America/Toronto', 318 '514': 'America/Toronto', '579': 'America/Toronto', '581': 'America/Toronto', 319 '819': 'America/Toronto', 320 321 // Atlantic (America/Halifax) 322 '506': 'America/Halifax', '709': 'America/Halifax', '782': 'America/Halifax', 323 '902': 'America/Halifax', 324 325 // Newfoundland (America/St_Johns) 326 // 709 is shared NL/Atlantic, default to Halifax above 327 328 // Central (America/Winnipeg) 329 '204': 'America/Winnipeg', '431': 'America/Winnipeg', 330 331 // Saskatchewan (America/Regina — no DST) 332 '306': 'America/Regina', '639': 'America/Regina', 333 334 // Mountain (America/Edmonton) 335 '403': 'America/Edmonton', '587': 'America/Edmonton', '780': 'America/Edmonton', 336 '825': 'America/Edmonton', 337 338 // Pacific (America/Vancouver) 339 '236': 'America/Vancouver', '250': 'America/Vancouver', '604': 'America/Vancouver', 340 '672': 'America/Vancouver', '778': 'America/Vancouver', 341 }; 342 343 /** 344 * Detect timezone from a phone number's area code (US/CA only). 345 * This is a fallback when city-based detection has no data. 346 * 347 * @param {string|null} phone - E.164 phone number (e.g., '+16096197151') 348 * @returns {string|null} IANA timezone or null if not determinable 349 */ 350 export function timezoneFromPhone(phone) { 351 if (!phone || typeof phone !== 'string') return null; 352 353 // Strip to digits only 354 const digits = phone.replace(/\D/g, ''); 355 356 // US/CA numbers: country code 1 + 10 digits 357 if (digits.startsWith('1') && digits.length === 11) { 358 const areaCode = digits.substring(1, 4); 359 360 const usTimezone = US_AREA_CODE_TIMEZONES[areaCode]; 361 if (usTimezone) { 362 logger.info(`Detected timezone from US area code ${areaCode}: ${usTimezone}`); 363 return usTimezone; 364 } 365 366 const caTimezone = CA_AREA_CODE_TIMEZONES[areaCode]; 367 if (caTimezone) { 368 logger.info(`Detected timezone from CA area code ${areaCode}: ${caTimezone}`); 369 return caTimezone; 370 } 371 } 372 373 return null; // Not a US/CA number or area code not in map 374 } 375 376 /** 377 * Get timezone for a site from database 378 * Uses city+country as primary, phone area code as fallback (US/CA only) 379 * @param {number} siteId - Site ID 380 * @param {Database} db - Database instance 381 * @returns {string} - IANA timezone identifier 382 */ 383 export function getSiteTimezone(siteId, db) { 384 const site = db.prepare('SELECT city, country_code FROM sites WHERE id = ?').get(siteId); 385 386 if (!site) { 387 logger.warn(`Site ${siteId} not found, using default timezone`); 388 return 'America/New_York'; 389 } 390 391 // Primary: city + country lookup 392 const cityTimezone = detectTimezone(site.city, site.country_code); 393 394 // If we got a non-default result from city lookup, use it 395 // (detectTimezone returns America/New_York as fallback for unknown locations) 396 if (site.city && cityTimezone !== 'America/New_York') { 397 return cityTimezone; 398 } 399 400 // Fallback for US/CA: try phone area code from outreach contact 401 if (site.country_code === 'US' || site.country_code === 'CA') { 402 // Look up the phone number from the most recent outbound SMS for this site 403 const phoneRow = db 404 .prepare( 405 `SELECT contact_uri FROM messages 406 WHERE site_id = ? AND direction = 'outbound' AND contact_method = 'sms' 407 ORDER BY id DESC LIMIT 1` 408 ) 409 .get(siteId); 410 411 if (phoneRow?.contact_uri) { 412 const phoneTimezone = timezoneFromPhone(phoneRow.contact_uri); 413 if (phoneTimezone) { 414 logger.info( 415 `Site ${siteId}: using phone area code timezone ${phoneTimezone} (city lookup was default)` 416 ); 417 return phoneTimezone; 418 } 419 } 420 } 421 422 // Final fallback: country-level timezone (already determined by detectTimezone) 423 return cityTimezone; 424 } 425 426 export default { 427 detectTimezone, 428 getSiteTimezone, 429 timezoneFromPhone, 430 COUNTRY_TIMEZONES, 431 CITY_TIMEZONES, 432 US_AREA_CODE_TIMEZONES, 433 CA_AREA_CODE_TIMEZONES, 434 };