// services/callTest/config.js // Env defaults + optional per-store entry overrides from config/calltest-stores.json. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const STORES_CONFIG_PATH = path.resolve(__dirname, '../../config/calltest-stores.json'); let _storeOverridesCache = null; function envInt(name, fallback) { const raw = process.env[name]; if (raw == null || raw === '') return fallback; const n = Number(raw); return Number.isFinite(n) ? n : fallback; } function envStr(name, fallback) { const raw = process.env[name]; return raw != null && String(raw).trim() ? String(raw).trim() : fallback; } export function getWebhookBaseUrl() { const base = envStr('TWILIO_WEBHOOK_BASE_URL', ''); return base.replace(/\/+$/, ''); } export function loadStoreOverrides() { if (_storeOverridesCache) return _storeOverridesCache; try { if (!fs.existsSync(STORES_CONFIG_PATH)) { _storeOverridesCache = {}; return _storeOverridesCache; } const raw = fs.readFileSync(STORES_CONFIG_PATH, 'utf8'); const parsed = JSON.parse(raw); _storeOverridesCache = parsed && typeof parsed === 'object' ? parsed : {}; return _storeOverridesCache; } catch { _storeOverridesCache = {}; return _storeOverridesCache; } } /** @param {string|null} storeNum */ export function getCallTestConfig(storeNum = null) { const overrides = storeNum ? (loadStoreOverrides()[String(storeNum)] || {}) : {}; return { dtmf: String(overrides.dtmf ?? envStr('CALLTEST_DTMF', '1')), greetingPauseSec: Number(overrides.greetingPauseSec ?? envInt('CALLTEST_GREETING_PAUSE_SEC', 5)), answerWaitSec: Number(overrides.answerWaitSec ?? envInt('CALLTEST_ANSWER_WAIT_SEC', 45)), dialTimeoutSec: envInt('CALLTEST_DIAL_TIMEOUT_SEC', 30), listenSec: envInt('CALLTEST_LISTEN_SEC', 60), introTts: envStr( 'CALLTEST_INTRO_TTS', 'Hello. This is an automated connectivity test from Collab Support. We will listen on this line for sixty seconds. Please stay on the line.', ), outroTts: envStr( 'CALLTEST_OUTRO_TTS', 'Thank you. The connectivity test is complete. Goodbye.', ), cdrEnrich: (() => { const v = String(process.env.CALLTEST_CDR_ENRICH ?? 'true').toLowerCase(); return v === 'true' || v === '1' || v === 'yes'; })(), cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000), cdrMatchBufferMs: envInt('CALLTEST_CDR_MATCH_BUFFER_MS', 120_000), twilioEnrich: (() => { const v = String(process.env.CALLTEST_TWILIO_ENRICH ?? 'true').toLowerCase(); return v === 'true' || v === '1' || v === 'yes'; })(), twilioDetailsDelayMs: envInt('CALLTEST_TWILIO_DETAILS_DELAY_MS', 10_000), twilioInsightsDelayMs: envInt('CALLTEST_TWILIO_INSIGHTS_DELAY_MS', 360_000), twilioInsightsRetries: envInt('CALLTEST_TWILIO_INSIGHTS_RETRIES', 3), twilioInsightsRetryIntervalMs: envInt('CALLTEST_TWILIO_INSIGHTS_RETRY_MS', 30_000), }; } export function buildVoiceWebhookUrl(testId, mode, step) { const base = getWebhookBaseUrl(); const q = new URLSearchParams({ testId, mode, step }); return `${base}/twilio/calltest/voice?${q.toString()}`; } export function buildStatusWebhookUrl(testId) { const base = getWebhookBaseUrl(); const q = new URLSearchParams({ testId }); return `${base}/twilio/calltest/status?${q.toString()}`; } /** * Exact webhook URL Twilio was given for this inbound request. Uses the same * builders as outbound call setup / TwiML redirects so proxy path stripping * (e.g. NGINX /CollabSupport/ → backend) cannot desync validation. */ export function resolveCallTestWebhookUrl(req) { const routePath = req.path || req.originalUrl?.split('?')[0] || ''; if (routePath.endsWith('/twilio/calltest/voice')) { const testId = String(req.query?.testId || '').trim(); const mode = req.query?.mode === 'store' ? 'store' : 'dial'; const step = String(req.query?.step || '').trim(); if (!testId || !step) return null; return buildVoiceWebhookUrl(testId, mode, step); } if (routePath.endsWith('/twilio/calltest/status')) { const testId = String(req.query?.testId || '').trim(); if (!testId) return null; return buildStatusWebhookUrl(testId); } return null; } /** Normalize user input to E.164 (+1...) */ export function normalizeE164(input) { if (!input) return null; let s = String(input).trim().replace(/[^\d+]/g, ''); if (s.startsWith('+')) { const digits = s.slice(1); if (!/^\d{10,15}$/.test(digits)) return null; return `+${digits}`; } const digits = s.replace(/\D/g, ''); if (digits.length === 10) return `+1${digits}`; if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`; return null; } export function isStoreNumberToken(token) { return /^\d{2,4}$/.test(String(token || '').trim()); } export function _resetStoreOverridesCacheForTests() { _storeOverridesCache = null; }