/ src / utils / timezone-detector.js
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  };