/** * In-memory reverse phone-number directory backing * server/api/telephony/cid-lookup/[number].get.ts. Split out from the route * so server/plugins/warm-cid-lookup.ts can pre-fill it at server boot * without importing a dynamic route file. * * Source is iDempiere ad_user (phone/phone2), fetched with the SERVICE token * (config.api.idempieretoken — same pattern as phoneActivity.ts/extensions.ts; * there is no user session here). The full "active users with a phone number" * set is small (~470 of ~62k ad_user rows on prod) and cheap (~150 ms), so it * is kept whole in memory with a short TTL rather than queried per lookup — * see the file header of cid-lookup/[number].get.ts for why a live per-call * iDempiere dependency is the wrong shape for a synchronous Asterisk CURL(). */ import { string } from 'alga-js' export interface DirectoryEntry { name: string; digits: string[] } export const digitsOf = (n: any) => String(n ?? '').replace(/\D+/g, '') /** Last N significant digits, used to compare numbers regardless of country/trunk prefix. */ export const tailOf = (digits: string, len = 8) => digits.slice(-len) /** True if one digit string ends with the other (handles a shorter stored/caller number). */ export const tailsMatch = (a: string, b: string, minLen = 6): boolean => { if (a.length < minLen || b.length < minLen) return false return a.length >= b.length ? a.endsWith(b) : b.endsWith(a) } let directoryCache: DirectoryEntry[] = [] let cacheExpiresAt = 0 let refreshInFlight: Promise | null = null const CACHE_TTL_MS = 60_000 // how stale a name can be after an iDempiere edit const BACKOFF_MS = 15_000 // don't hammer iDempiere every call while it's down const FETCH_TIMEOUT_MS = 3_000 // cold-start budget; well inside Asterisk's CURL wait async function fetchDirectory(): Promise { const config = useRuntimeConfig() const token = (config.api as any)?.idempieretoken if (!token) return [] const filter = string.urlEncode('IsActive eq true and (phone neq null or phone2 neq null)') const res: any = await $fetch( `${config.api.url}/models/ad_user?$select=Name,Phone,Phone2&$filter=${filter}&$top=5000`, { headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, retry: 0, timeout: FETCH_TIMEOUT_MS } ) const out: DirectoryEntry[] = [] for (const r of (res?.records || [])) { const name = String(r?.Name ?? r?.name ?? '').trim() if (!name) continue const digits = [digitsOf(r?.Phone ?? r?.phone), digitsOf(r?.Phone2 ?? r?.phone2)].filter((d) => d.length >= 6) if (digits.length) out.push({ name, digits }) } return out } /** Warm cache: instant. Expired cache: served as-is, refreshed in the background. Cold: blocks once. */ export async function getCidDirectory(): Promise { if (Date.now() < cacheExpiresAt) return directoryCache if (directoryCache.length === 0) { try { directoryCache = await fetchDirectory() cacheExpiresAt = Date.now() + CACHE_TTL_MS } catch (err: any) { console.warn('[cid-lookup] iDempiere directory fetch failed:', err?.message || err) cacheExpiresAt = Date.now() + BACKOFF_MS } return directoryCache } if (!refreshInFlight) { refreshInFlight = fetchDirectory() .then((fresh) => { directoryCache = fresh; cacheExpiresAt = Date.now() + CACHE_TTL_MS }) .catch((err: any) => { console.warn('[cid-lookup] iDempiere directory refresh failed:', err?.message || err) cacheExpiresAt = Date.now() + BACKOFF_MS }) .finally(() => { refreshInFlight = null }) } return directoryCache }