// services/callTest/locationResolver.js // Resolve Webex Calling location from a dialed E.164 (store main / AA DID). import { logger } from '../../utils/logger.js'; import { normalizeE164 } from './config.js'; const LOG_SCOPE = 'calltest:location'; const CACHE_TTL_MS = 60 * 60 * 1000; let _numbersCache = null; let _numbersCacheAt = 0; async function getWebex() { const mod = await import('../../integrations/webex/WebexClient.js'); return mod.default; } function parseLinkNext(linkHeader) { if (!linkHeader || typeof linkHeader !== 'string') return null; for (const part of linkHeader.split(',')) { const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i); if (m) return m[1]; } return null; } export function phoneDigits(value) { const d = String(value || '').replace(/\D/g, ''); if (d.length === 10) return `1${d}`; if (d.length === 11 && d.startsWith('1')) return d; return d; } function numberRecordFromApi(n) { const raw = n.phoneNumber || n.number || n.value; const loc = n.location; if (!raw || !loc?.id) return null; return { locationId: loc.id, locationName: loc.name || null, phoneNumber: raw, extension: n.extension || n.primaryExtension || null, owner: n.owner || null, raw: n, }; } async function fetchAllNumbers() { const webex = await getWebex(); const all = []; let nextUrl = null; let data; let headers; ({ data, headers } = await webex.requestRaw('GET', 'telephony/config/numbers', null, { max: 1000 })); const batch = data?.phoneNumbers || []; all.push(...batch); nextUrl = parseLinkNext(headers?.link || headers?.Link); while (nextUrl) { ({ data, headers } = await webex.requestRaw('GET', nextUrl)); all.push(...(data?.phoneNumbers || [])); nextUrl = parseLinkNext(headers?.link || headers?.Link); } return all; } async function buildNumbersCache() { const now = Date.now(); if (_numbersCache && now - _numbersCacheAt < CACHE_TTL_MS) { return _numbersCache; } logger(LOG_SCOPE, 'Building location phone index from telephony/config/numbers', 'debug'); const numbers = await fetchAllNumbers(); const byPhoneDigits = new Map(); const byPersonId = new Map(); const records = []; for (const n of numbers) { const entry = numberRecordFromApi(n); if (!entry) continue; records.push(entry); const keys = new Set([phoneDigits(entry.phoneNumber), phoneDigits(normalizeE164(entry.phoneNumber) || entry.phoneNumber)]); for (const k of keys) { if (k) byPhoneDigits.set(k, entry); } const ownerId = entry.owner?.id; if (ownerId) { if (!byPersonId.has(ownerId)) byPersonId.set(ownerId, []); byPersonId.get(ownerId).push(entry); } } _numbersCache = { byPhoneDigits, byPersonId, records }; _numbersCacheAt = now; logger( LOG_SCOPE, `Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners from ${numbers.length} numbers`, 'debug', ); return _numbersCache; } function addMatchKey(set, value) { if (value == null || value === '') return; const s = String(value).trim(); if (!s) return; set.add(s.toLowerCase()); const digits = s.replace(/\D/g, ''); if (digits) { set.add(digits); if (digits.length >= 4) set.add(digits.slice(-4)); if (digits.length >= 5) set.add(digits.slice(-5)); } } /** * Build CDR match keys for a provisioned phone number record. * @param {object} record from numberRecordFromApi * @returns {Set} */ export function buildMatchKeysForNumber(record) { const keys = new Set(); if (!record) return keys; addMatchKey(keys, record.phoneNumber); addMatchKey(keys, normalizeE164(record.phoneNumber)); addMatchKey(keys, record.extension); if (record.owner?.name) addMatchKey(keys, record.owner.name); return keys; } /** * Build CDR match keys for a Webex person + their owned numbers. * @param {object} person * @param {object[]} ownedNumbers * @returns {Set} */ export function buildMatchKeysForPerson(person, ownedNumbers = []) { const keys = new Set(); if (!person) return keys; addMatchKey(keys, person.displayName); (person.emails || []).forEach((e) => addMatchKey(keys, e)); (person.phoneNumbers || []).forEach((p) => addMatchKey(keys, p?.value || p)); for (const record of ownedNumbers) { for (const k of buildMatchKeysForNumber(record)) keys.add(k); } return keys; } /** * Find Webex location for a dialed E.164 (e.g. store main on /calltest dial). * @returns {Promise<{locationId: string, locationName: string, phoneNumber: string}|null>} */ export async function resolveLocationForDialNumber(dialNumber) { const assignment = await resolveNumberAssignment(dialNumber); if (!assignment?.locationName) return null; return { locationId: assignment.locationId, locationName: assignment.locationName, phoneNumber: assignment.phoneNumber, }; } /** * Full number assignment details for a dialed E.164. * @returns {Promise} */ export async function resolveNumberAssignment(dialNumber) { const e164 = normalizeE164(dialNumber); if (!e164) return null; try { const cache = await buildNumbersCache(); const key = phoneDigits(e164); return cache.byPhoneDigits.get(key) || null; } catch (err) { logger(LOG_SCOPE, `resolveNumberAssignment failed: ${err.message}`, 'warn'); return null; } } /** * Resolve location from numbers owned by a person. * @returns {Promise<{ locationId, locationName, ownedNumbers }|null>} */ export async function resolvePersonLocation(personId) { if (!personId) return null; try { const cache = await buildNumbersCache(); const ownedNumbers = cache.byPersonId.get(personId) || []; if (ownedNumbers.length > 0) { const first = ownedNumbers[0]; return { locationId: first.locationId, locationName: first.locationName, ownedNumbers, }; } return null; } catch (err) { logger(LOG_SCOPE, `resolvePersonLocation failed: ${err.message}`, 'warn'); return null; } } /** * @deprecated Use buildNumbersCache internally; kept for tests. */ export async function buildPhoneIndex() { const cache = await buildNumbersCache(); return cache.byPhoneDigits; } export function _clearLocationCacheForTests() { _numbersCache = null; _numbersCacheAt = 0; }