import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { DEFAULT_TELEPHONY_CONFIG, TELEPHONY_ENV_SEEDS, type TelephonyConfig, } from './telephony.defaults'; const CONFIG_PATH = join(process.cwd(), 'data', 'telephony.json'); const BACKUP_DIR = join(process.cwd(), 'data', 'telephony-backups'); // File-backed telephony config. Replaces eight env vars (OZONETEL_*, SIP_*, // EXOTEL_*). On first boot we copy whatever those env vars hold into the // config file so existing deployments don't break — after that, the env vars // are no longer read by anything. // // Mirrors WidgetConfigService and ThemeService — load on init, in-memory // cache, file backups on every change. @Injectable() export class TelephonyConfigService implements OnModuleInit { private readonly logger = new Logger(TelephonyConfigService.name); private cached: TelephonyConfig | null = null; onModuleInit() { this.ensureReady(); } getConfig(): TelephonyConfig { if (this.cached) return this.cached; return this.load(); } // Public-facing subset for the GET endpoint — masks the Exotel API token // so it can't be exfiltrated by an unauthenticated reader. The admin UI // gets the full config via getConfig() through the controller's PUT path // (the new value is supplied client-side, the old value is never displayed). getMaskedConfig() { const c = this.getConfig(); return { ...c, exotel: { ...c.exotel, apiToken: c.exotel.apiToken ? '***masked***' : '', }, ozonetel: { ...c.ozonetel, agentPassword: c.ozonetel.agentPassword ? '***masked***' : '', adminPassword: c.ozonetel.adminPassword ? '***masked***' : '', }, }; } updateConfig(updates: Partial): TelephonyConfig { const current = this.getConfig(); // Deep-ish merge — each top-level group merges its own keys. const merged: TelephonyConfig = { ozonetel: { ...current.ozonetel, ...(updates.ozonetel ?? {}) }, sip: { ...current.sip, ...(updates.sip ?? {}) }, exotel: { ...current.exotel, ...(updates.exotel ?? {}) }, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; // Strip the masked sentinel — admin UI sends back '***masked***' for // unchanged secret fields. We treat that as "keep the existing value". if (merged.exotel.apiToken === '***masked***') { merged.exotel.apiToken = current.exotel.apiToken; } if (merged.ozonetel.agentPassword === '***masked***') { merged.ozonetel.agentPassword = current.ozonetel.agentPassword; } if (merged.ozonetel.adminPassword === '***masked***') { merged.ozonetel.adminPassword = current.ozonetel.adminPassword; } this.backup(); this.writeFile(merged); this.cached = merged; this.logger.log(`Telephony config updated to v${merged.version}`); return merged; } resetConfig(): TelephonyConfig { this.backup(); const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig; this.writeFile(fresh); this.cached = fresh; this.logger.log('Telephony config reset to defaults'); return fresh; } // First-boot bootstrap: if no telephony.json exists yet, seed it from the // legacy env vars. After this runs once the env vars are dead code. private ensureReady(): TelephonyConfig { if (existsSync(CONFIG_PATH)) { return this.load(); } const seeded: TelephonyConfig = JSON.parse( JSON.stringify(DEFAULT_TELEPHONY_CONFIG), ) as TelephonyConfig; let appliedCount = 0; for (const seed of TELEPHONY_ENV_SEEDS) { const value = process.env[seed.env]; if (value === undefined || value === '') continue; this.setNested(seeded, seed.path, value); appliedCount += 1; } seeded.version = 1; seeded.updatedAt = new Date().toISOString(); this.writeFile(seeded); this.cached = seeded; this.logger.log( `Telephony config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`, ); return seeded; } private load(): TelephonyConfig { try { const raw = readFileSync(CONFIG_PATH, 'utf8'); const parsed = JSON.parse(raw); const merged: TelephonyConfig = { ozonetel: { ...DEFAULT_TELEPHONY_CONFIG.ozonetel, ...(parsed.ozonetel ?? {}) }, sip: { ...DEFAULT_TELEPHONY_CONFIG.sip, ...(parsed.sip ?? {}) }, exotel: { ...DEFAULT_TELEPHONY_CONFIG.exotel, ...(parsed.exotel ?? {}) }, version: parsed.version, updatedAt: parsed.updatedAt, }; this.cached = merged; this.logger.log('Telephony config loaded from file'); return merged; } catch (err) { this.logger.warn(`Failed to load telephony config, using defaults: ${err}`); const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig; this.cached = fresh; return fresh; } } private setNested(obj: any, path: string[], value: string) { let cursor = obj; for (let i = 0; i < path.length - 1; i++) { if (!cursor[path[i]]) cursor[path[i]] = {}; cursor = cursor[path[i]]; } cursor[path[path.length - 1]] = value; } private writeFile(cfg: TelephonyConfig) { const dir = dirname(CONFIG_PATH); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8'); } private backup() { try { if (!existsSync(CONFIG_PATH)) return; if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, '-'); copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `telephony-${ts}.json`)); } catch (err) { this.logger.warn(`Telephony backup failed: ${err}`); } } }