collabSupport/services/callTest/locationResolver.js
jmcqueen 2ec2b7a486 Add /resetvmpin and /resetvppin with confirmation cards for Webex PIN resets.
Chat-only commands reset voicemail mailbox and voice portal passcodes via telephony_config_write, with extension lookup and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 16:34:09 -04:00

255 lines
7.1 KiB
JavaScript

// 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 byExtension = 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);
}
if (entry.extension) {
const ext = String(entry.extension).trim();
if (ext) {
if (!byExtension.has(ext)) byExtension.set(ext, []);
byExtension.get(ext).push(entry);
}
}
}
_numbersCache = { byPhoneDigits, byPersonId, byExtension, records };
_numbersCacheAt = now;
logger(
LOG_SCOPE,
`Phone index built: ${byPhoneDigits.size} phone keys, ${byPersonId.size} owners, ${byExtension.size} extensions 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<string>}
*/
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<string>}
*/
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<object|null>}
*/
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;
}
/**
* Find telephony number records assigned to a Webex extension.
* @param {string} extension
* @returns {Promise<object[]>}
*/
export async function resolveRecordsByExtension(extension) {
if (!extension) return [];
try {
const cache = await buildNumbersCache();
const ext = String(extension).trim();
return cache.byExtension.get(ext) || [];
} catch (err) {
logger(LOG_SCOPE, `resolveRecordsByExtension failed: ${err.message}`, 'warn');
return [];
}
}
export function _clearLocationCacheForTests() {
_numbersCache = null;
_numbersCacheAt = 0;
}
export function _setNumbersCacheForTests(cache) {
_numbersCache = cache;
_numbersCacheAt = Date.now();
}