/** * targo-hub — Lightweight SSE relay for real-time Communication events. * * Endpoints: * GET /sse?topics=customer:C-LPB4,customer:C-114796350603272 * → SSE stream, authenticated via X-Authentik-Email header (Traefik injects it) * * POST /broadcast * → Push an event to all SSE clients subscribed to matching topics * → Auth: Bearer token (HUB_INTERNAL_TOKEN) * → Body: { topic, event, data } * * POST /webhook/twilio/sms-incoming * → Receive Twilio inbound SMS, log to ERPNext, broadcast SSE * * POST /webhook/twilio/sms-status * → Receive Twilio delivery status updates * * GET /health * → Health check * * 3CX Call Log Poller: * → Polls 3CX xAPI every 30s for new completed calls * → Logs to ERPNext Communication, broadcasts SSE * * GET|POST|DELETE /devices/* * → GenieACS NBI proxy for CPE/ONT device management * → /devices/summary — fleet stats, /devices/lookup?serial=X — find device * → /devices/:id/tasks — send reboot, getParameterValues, etc. */ const http = require('http') const https = require('https') const { URL } = require('url') // ── Config ── const PORT = parseInt(process.env.PORT || '3300', 10) const INTERNAL_TOKEN = process.env.HUB_INTERNAL_TOKEN || '' const ERP_URL = process.env.ERP_URL || 'http://erpnext-backend:8000' const ERP_TOKEN = process.env.ERP_TOKEN || '' const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || '' const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || '' const TWILIO_FROM = process.env.TWILIO_FROM || '' // 3CX Config (DISABLED by default — set PBX_ENABLED=1 to re-enable) const PBX_ENABLED = process.env.PBX_ENABLED === '1' const PBX_URL = process.env.PBX_URL || 'https://targopbx.3cx.ca' const PBX_USER = process.env.PBX_USER || '' const PBX_PASS = process.env.PBX_PASS || '' const PBX_POLL_INTERVAL = parseInt(process.env.PBX_POLL_INTERVAL || '30000', 10) // 30s // Twilio Voice Config const TWILIO_API_KEY = process.env.TWILIO_API_KEY || '' const TWILIO_API_SECRET = process.env.TWILIO_API_SECRET || '' const TWILIO_TWIML_APP_SID = process.env.TWILIO_TWIML_APP_SID || '' // Fonoster/Routr DB Config (direct PostgreSQL access) const ROUTR_DB_URL = process.env.ROUTR_DB_URL || '' const FNIDENTITY_DB_URL = process.env.FNIDENTITY_DB_URL || '' // GenieACS NBI Config const GENIEACS_NBI_URL = process.env.GENIEACS_NBI_URL || 'http://10.5.2.115:7557' // ── SSE Client Registry ── // Map> const subscribers = new Map() let clientIdSeq = 0 function addClient (topics, res, email) { const id = ++clientIdSeq const client = { id, res, email, topics } for (const t of topics) { if (!subscribers.has(t)) subscribers.set(t, new Set()) subscribers.get(t).add(client) } // Remove on disconnect res.on('close', () => { for (const t of topics) { const set = subscribers.get(t) if (set) { set.delete(client) if (set.size === 0) subscribers.delete(t) } } log(`SSE client #${id} disconnected (${email})`) }) log(`SSE client #${id} connected (${email}) topics=[${topics.join(',')}]`) return id } function broadcast (topic, event, data) { const set = subscribers.get(topic) if (!set || set.size === 0) return 0 const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` let count = 0 for (const client of set) { try { client.res.write(payload) count++ } catch { // Client gone, will be cleaned up on close } } return count } function broadcastAll (event, data) { // Broadcast to ALL connected clients regardless of topic const sent = new Set() let count = 0 for (const [, set] of subscribers) { for (const client of set) { if (!sent.has(client.id)) { sent.add(client.id) try { client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) count++ } catch {} } } } return count } // ── ERPNext helpers ── const ERP_SITE = process.env.ERP_SITE || 'erp.gigafibre.ca' function erpFetch (path, opts = {}) { const url = ERP_URL + path return new Promise((resolve, reject) => { const parsed = new URL(url) const reqOpts = { hostname: parsed.hostname, port: parsed.port || 8000, path: parsed.pathname + parsed.search, method: opts.method || 'GET', headers: { Host: ERP_SITE, // Required: Frappe multi-tenant routing needs the site name Authorization: 'token ' + ERP_TOKEN, 'Content-Type': 'application/json', ...opts.headers, }, } const req = http.request(reqOpts, (res) => { let body = '' res.on('data', c => body += c) res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(body) }) } catch { resolve({ status: res.statusCode, data: body }) } }) }) req.on('error', reject) if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) req.end() }) } async function lookupCustomerByPhone (phone) { // Normalize to last 10 digits const digits = phone.replace(/\D/g, '').slice(-10) const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office']) // Search across all phone fields: cell_phone, tel_home, tel_office for (const field of ['cell_phone', 'tel_home', 'tel_office']) { const filters = JSON.stringify([[field, 'like', '%' + digits]]) const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1` try { const res = await erpFetch(path) if (res.status === 200 && res.data.data && res.data.data.length > 0) { return res.data.data[0] } } catch (e) { log('lookupCustomerByPhone error on ' + field + ':', e.message) } } return null } async function createCommunication (fields) { return erpFetch('/api/resource/Communication', { method: 'POST', body: JSON.stringify(fields), }) } // ── Request body parser ── function parseBody (req) { return new Promise((resolve, reject) => { const chunks = [] req.on('data', c => chunks.push(c)) req.on('end', () => { const raw = Buffer.concat(chunks).toString() const ct = (req.headers['content-type'] || '').toLowerCase() if (ct.includes('application/json')) { try { resolve(JSON.parse(raw)) } catch { resolve({}) } } else if (ct.includes('urlencoded')) { // Twilio sends application/x-www-form-urlencoded const params = new URLSearchParams(raw) const obj = {} for (const [k, v] of params) obj[k] = v resolve(obj) } else { resolve(raw) } }) req.on('error', reject) }) } // ── Logging ── function log (...args) { console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...args) } // ── HTTP Server ── const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`) const path = url.pathname const method = req.method // CORS headers (for browser SSE + POST from ops/client apps) res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authentik-Email, X-Authentik-Groups') if (method === 'OPTIONS') { res.writeHead(204) return res.end() } try { // ─── Health ─── if (path === '/health') { const clientCount = new Set() for (const [, set] of subscribers) for (const c of set) clientCount.add(c.id) return json(res, 200, { ok: true, clients: clientCount.size, topics: subscribers.size, uptime: Math.floor(process.uptime()), }) } // ─── SSE endpoint ─── if (path === '/sse' && method === 'GET') { const email = req.headers['x-authentik-email'] || 'anonymous' const topicsParam = url.searchParams.get('topics') || '' const topics = topicsParam.split(',').map(t => t.trim()).filter(Boolean) if (!topics.length) { return json(res, 400, { error: 'Missing topics parameter' }) } // Set SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', // Disable nginx/traefik buffering }) // Send initial comment to establish connection res.write(': connected\n\n') const clientId = addClient(topics, res, email) // Keepalive ping every 25s const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000) res.on('close', () => clearInterval(keepalive)) return } // ─── Broadcast (internal) ─── if (path === '/broadcast' && method === 'POST') { // Auth check const auth = req.headers.authorization || '' if (INTERNAL_TOKEN && auth !== 'Bearer ' + INTERNAL_TOKEN) { return json(res, 401, { error: 'Unauthorized' }) } const body = await parseBody(req) const { topic, event, data } = body if (!topic || !event) { return json(res, 400, { error: 'Missing topic or event' }) } const count = broadcast(topic, event || 'message', data || {}) return json(res, 200, { ok: true, delivered: count }) } // ─── Twilio SMS Incoming ─── if (path === '/webhook/twilio/sms-incoming' && method === 'POST') { const body = await parseBody(req) const from = body.From || '' const to = body.To || '' const text = body.Body || '' const sid = body.MessageSid || '' log(`SMS IN: ${from} → ${to}: ${text.substring(0, 50)}...`) // Respond to Twilio immediately (they expect TwiML or 200) res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') // Process async: lookup customer, log, broadcast setImmediate(async () => { try { const customer = await lookupCustomerByPhone(from) const customerName = customer ? customer.name : null const customerLabel = customer ? customer.customer_name : 'Inconnu' // Log Communication in ERPNext if (customerName) { await createCommunication({ communication_type: 'Communication', communication_medium: 'SMS', sent_or_received: 'Received', sender: 'sms@gigafibre.ca', sender_full_name: customerLabel, phone_no: from, content: text, subject: 'SMS from ' + from, reference_doctype: 'Customer', reference_name: customerName, message_id: sid, status: 'Open', }) } // Broadcast SSE event const eventData = { type: 'sms', direction: 'in', customer: customerName, customer_name: customerLabel, phone: from, text, sid, ts: new Date().toISOString(), } let n = 0 if (customerName) { n = broadcast('customer:' + customerName, 'message', eventData) } // Also broadcast to global topic for dispatch/monitoring broadcastAll('sms-incoming', eventData) log(`SMS logged: ${from} → ${customerName || 'UNKNOWN'} (${sid}) broadcast=${n}`) } catch (e) { log('SMS processing error:', e.message) } }) return } // ─── Twilio SMS Status ─── if (path === '/webhook/twilio/sms-status' && method === 'POST') { const body = await parseBody(req) const sid = body.MessageSid || body.SmsSid || '' const status = body.MessageStatus || body.SmsStatus || '' log(`SMS STATUS: ${sid} → ${status}`) // Respond immediately res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') // Broadcast status update setImmediate(() => { broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() }) }) return } // ─── Send SMS (outbound, called from ops/client apps) ─── if (path === '/send/sms' && method === 'POST') { const body = await parseBody(req) const { phone, message, customer } = body if (!phone || !message) { return json(res, 400, { error: 'Missing phone or message' }) } // Send via Twilio const twilioData = new URLSearchParams({ To: phone.startsWith('+') ? phone : '+' + phone, From: TWILIO_FROM, Body: message, StatusCallback: (process.env.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/webhook/twilio/sms-status', }) const twilioRes = await new Promise((resolve, reject) => { const authStr = Buffer.from(TWILIO_ACCOUNT_SID + ':' + TWILIO_AUTH_TOKEN).toString('base64') const twilioReq = require('https').request({ hostname: 'api.twilio.com', path: `/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`, method: 'POST', headers: { Authorization: 'Basic ' + authStr, 'Content-Type': 'application/x-www-form-urlencoded', }, }, (res) => { let body = '' res.on('data', c => body += c) res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(body) }) } catch { resolve({ status: res.statusCode, data: body }) } }) }) twilioReq.on('error', reject) twilioReq.write(twilioData.toString()) twilioReq.end() }) if (twilioRes.status >= 400) { log('Twilio send error:', twilioRes.data) return json(res, 502, { ok: false, error: 'Twilio error', details: twilioRes.data }) } const sid = twilioRes.data.sid || '' log(`SMS OUT: → ${phone}: ${message.substring(0, 50)}... (${sid})`) // Log Communication in ERPNext if (customer) { await createCommunication({ communication_type: 'Communication', communication_medium: 'SMS', sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops', phone_no: phone, content: message, subject: 'SMS to ' + phone, reference_doctype: 'Customer', reference_name: customer, message_id: sid, status: 'Linked', }) // Broadcast SSE broadcast('customer:' + customer, 'message', { type: 'sms', direction: 'out', customer, phone, text: message, sid, ts: new Date().toISOString(), }) } return json(res, 200, { ok: true, sid }) } // ─── Twilio Voice: Generate Access Token ─── if (path === '/voice/token' && method === 'GET') { if (!TWILIO_API_KEY || !TWILIO_API_SECRET || !TWILIO_TWIML_APP_SID) { return json(res, 503, { error: 'Twilio Voice not configured' }) } const identity = url.searchParams.get('identity') || req.headers['x-authentik-email'] || 'ops-agent' try { const twilio = require('twilio') const AccessToken = twilio.jwt.AccessToken const VoiceGrant = AccessToken.VoiceGrant const token = new AccessToken( TWILIO_ACCOUNT_SID, TWILIO_API_KEY, TWILIO_API_SECRET, { identity, ttl: 3600 } ) const grant = new VoiceGrant({ outgoingApplicationSid: TWILIO_TWIML_APP_SID, incomingAllow: true, }) token.addGrant(grant) log(`Voice token generated for ${identity}`) return json(res, 200, { token: token.toJwt(), identity }) } catch (e) { log('Voice token error:', e.message) return json(res, 500, { error: 'Token generation failed: ' + e.message }) } } // ─── Fonoster SIP: Get SIP config for WebRTC client ─── if (path === '/voice/sip-config' && method === 'GET') { // Return SIP credentials for the browser client // These are configured via env vars or fetched from Fonoster API const sipConfig = { wssUrl: process.env.SIP_WSS_URL || 'wss://voice.gigafibre.ca:5063', domain: process.env.SIP_DOMAIN || 'voice.gigafibre.ca', extension: process.env.SIP_EXTENSION || '1001', authId: process.env.SIP_AUTH_ID || '1001', authPassword: process.env.SIP_AUTH_PASSWORD || '', displayName: process.env.SIP_DISPLAY_NAME || 'Targo Ops', identity: process.env.SIP_EXTENSION || '1001', } return json(res, 200, sipConfig) } // ─── Twilio Voice: TwiML for outbound calls ─── if (path === '/voice/twiml' && method === 'POST') { const body = await parseBody(req) const to = body.To || body.phone || '' log(`Voice TwiML: dialing ${to}`) // Return TwiML to dial the number const callerId = TWILIO_FROM || '+14382313838' const twiml = ` ${to} ` res.writeHead(200, { 'Content-Type': 'text/xml' }) return res.end(twiml) } // ─── Twilio Voice: Call status callback ─── if (path === '/voice/status' && method === 'POST') { const body = await parseBody(req) const callSid = body.CallSid || '' const callStatus = body.CallStatus || '' const to = body.To || '' const from = body.From || '' const duration = parseInt(body.CallDuration || '0', 10) log(`Voice STATUS: ${callSid} ${from}→${to} status=${callStatus} dur=${duration}s`) res.writeHead(200, { 'Content-Type': 'text/xml' }) res.end('') // On completed, log to ERPNext and broadcast if (callStatus === 'completed' && duration > 0) { setImmediate(async () => { try { const phone = to.startsWith('client:') ? from : to const customer = await lookupCustomerByPhone(phone) const customerName = customer ? customer.name : null if (customerName) { const durationMin = Math.floor(duration / 60) const durationSec = duration % 60 const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s` await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops', phone_no: phone, content: `Appel vers ${phone} — Duree: ${durationStr}`, subject: `Appel vers ${phone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) broadcast('customer:' + customerName, 'call-event', { type: 'call', event: 'completed', direction: 'out', customer: customerName, phone, duration, call_id: callSid, ts: new Date().toISOString(), }) log(`Voice logged: ${phone} → ${customerName} (${durationStr})`) } } catch (e) { log('Voice status processing error:', e.message) } }) } return } // ─── 3CX Call Event Webhook (CRM Integration) ─── if (path === '/webhook/3cx/call-event' && method === 'POST') { const body = await parseBody(req) // 3CX CRM Integration sends: event_type, call_id, direction, caller, callee, ext, duration, status, etc. const eventType = body.event_type || body.EventType || body.type || '' const callId = body.call_id || body.CallId || body.id || '' const direction = (body.direction || body.Direction || '').toLowerCase() // inbound / outbound const caller = body.caller || body.Caller || body.from || '' const callee = body.callee || body.Callee || body.to || '' const ext = body.ext || body.Extension || body.extension || '' const duration = parseInt(body.duration || body.Duration || '0', 10) const status = body.status || body.Status || eventType // Determine the remote phone number (not the extension) const isOutbound = direction === 'outbound' || direction === 'out' const remotePhone = isOutbound ? callee : caller log(`3CX ${eventType}: ${caller} → ${callee} ext=${ext} dir=${direction} dur=${duration}s status=${status} (${callId})`) // Respond immediately json(res, 200, { ok: true }) // Process async: lookup customer, log communication, broadcast SSE setImmediate(async () => { try { // Only log completed calls (not ringing/answered events) to avoid duplicates const isCallEnd = ['ended', 'completed', 'hangup', 'Notified', 'missed'].some( s => eventType.toLowerCase().includes(s.toLowerCase()) || status.toLowerCase().includes(s.toLowerCase()) ) const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null const customerName = customer ? customer.name : null const customerLabel = customer ? customer.customer_name : 'Inconnu' // Log Communication in ERPNext for ended/completed calls if (isCallEnd && customerName && duration > 0) { const durationMin = Math.floor(duration / 60) const durationSec = duration % 60 const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s` await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: isOutbound ? 'Sent' : 'Received', sender: 'sms@gigafibre.ca', sender_full_name: isOutbound ? 'Targo Ops' : customerLabel, phone_no: remotePhone, content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${durationStr}`, subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) } // Broadcast SSE event for ALL call events (ringing, answered, ended) const eventData = { type: 'call', event: eventType, direction: isOutbound ? 'out' : 'in', customer: customerName, customer_name: customerLabel, phone: remotePhone, extension: ext, duration, status, call_id: callId, ts: new Date().toISOString(), } let n = 0 if (customerName) { n = broadcast('customer:' + customerName, 'call-event', eventData) } // Also broadcast globally for dispatch monitoring broadcastAll('call-event', eventData) log(`3CX logged: ${remotePhone} → ${customerName || 'UNKNOWN'} (${callId}) broadcast=${n}`) } catch (e) { log('3CX call processing error:', e.message) } }) return } // ─── Fonoster Telephony Management API ─── // All /telephony/* endpoints manage the Fonoster CPaaS (trunks, agents, credentials, numbers, domains) if (path.startsWith('/telephony/')) { return handleTelephony(req, res, method, path, url) } // ─── GenieACS Device Management API ─── // Proxy to GenieACS NBI for CPE/ONT management if (path.startsWith('/devices')) { return handleGenieACS(req, res, method, path, url) } // ─── GenieACS Config Export (provisions, presets, virtual params, files) ─── if (path.startsWith('/acs/')) { return handleACSConfig(req, res, method, path, url) } // ─── 404 ─── json(res, 404, { error: 'Not found' }) } catch (e) { log('ERROR:', e.message) json(res, 500, { error: 'Internal error' }) } }) function json (res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(data)) } // ── Fonoster Telephony Management (Direct DB) ── // Access Routr + Fonoster identity PostgreSQL databases directly let routrPool = null let identityPool = null function getRoutrPool () { if (!routrPool) { const { Pool } = require('pg') routrPool = new Pool({ connectionString: ROUTR_DB_URL, max: 3 }) routrPool.on('error', e => log('Routr DB pool error:', e.message)) } return routrPool } function getIdentityPool () { if (!identityPool) { const { Pool } = require('pg') identityPool = new Pool({ connectionString: FNIDENTITY_DB_URL, max: 3 }) identityPool.on('error', e => log('Identity DB pool error:', e.message)) } return identityPool } // Routr resource tables const ROUTR_TABLES = { trunks: 'trunks', agents: 'agents', credentials: 'credentials', numbers: 'numbers', domains: 'domains', acls: 'access_control_lists', peers: 'peers', } // Identity resource tables const IDENTITY_TABLES = { workspaces: 'workspaces', users: 'users', } async function handleTelephony (req, res, method, path, url) { try { const parts = path.replace('/telephony/', '').split('/').filter(Boolean) const resource = parts[0] const ref = parts[1] || null // GET /telephony/overview — Dashboard summary if (resource === 'overview' && method === 'GET') { const rPool = getRoutrPool() const iPool = getIdentityPool() const [trunks, agents, creds, numbers, domains, peers, workspaces] = await Promise.all([ rPool.query('SELECT COUNT(*) FROM trunks'), rPool.query('SELECT COUNT(*) FROM agents'), rPool.query('SELECT COUNT(*) FROM credentials'), rPool.query('SELECT COUNT(*) FROM numbers'), rPool.query('SELECT COUNT(*) FROM domains'), rPool.query('SELECT COUNT(*) FROM peers'), iPool.query('SELECT COUNT(*) FROM workspaces'), ]) return json(res, 200, { trunks: parseInt(trunks.rows[0].count), agents: parseInt(agents.rows[0].count), credentials: parseInt(creds.rows[0].count), numbers: parseInt(numbers.rows[0].count), domains: parseInt(domains.rows[0].count), peers: parseInt(peers.rows[0].count), workspaces: parseInt(workspaces.rows[0].count), }) } // Determine which database const isIdentity = IDENTITY_TABLES[resource] const tableName = IDENTITY_TABLES[resource] || ROUTR_TABLES[resource] if (!tableName) { return json(res, 404, { error: `Unknown resource: ${resource}. Available: overview, ${[...Object.keys(ROUTR_TABLES), ...Object.keys(IDENTITY_TABLES)].join(', ')}` }) } const pool = isIdentity ? getIdentityPool() : getRoutrPool() // GET /telephony/{resource} — List all if (method === 'GET' && !ref) { const limit = parseInt(url.searchParams.get('limit') || '100', 10) const offset = parseInt(url.searchParams.get('offset') || '0', 10) const result = await pool.query( `SELECT * FROM ${tableName} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, offset] ) const count = await pool.query(`SELECT COUNT(*) FROM ${tableName}`) return json(res, 200, { items: result.rows, total: parseInt(count.rows[0].count, 10) }) } // GET /telephony/{resource}/{ref} — Get by ref if (method === 'GET' && ref) { const result = await pool.query(`SELECT * FROM ${tableName} WHERE ref = $1`, [ref]) if (result.rows.length === 0) return json(res, 404, { error: 'Not found' }) return json(res, 200, result.rows[0]) } // POST /telephony/{resource} — Create if (method === 'POST' && !ref) { const body = await parseBody(req) // Generate ref if not provided if (!body.ref) { const crypto = require('crypto') body.ref = crypto.randomUUID() } if (!body.api_version) body.api_version = 'v2' if (!body.created_at) body.created_at = new Date() if (!body.updated_at) body.updated_at = new Date() const keys = Object.keys(body) const values = Object.values(body) const placeholders = keys.map((_, i) => `$${i + 1}`) const query = `INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *` const result = await pool.query(query, values) log(`Telephony: created ${resource}/${result.rows[0].ref}`) return json(res, 201, result.rows[0]) } // PUT /telephony/{resource}/{ref} — Update if (method === 'PUT' && ref) { const body = await parseBody(req) body.updated_at = new Date() delete body.ref delete body.created_at const keys = Object.keys(body) const values = Object.values(body) const setClauses = keys.map((k, i) => `${k} = $${i + 1}`) values.push(ref) const query = `UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE ref = $${values.length} RETURNING *` const result = await pool.query(query, values) if (result.rows.length === 0) return json(res, 404, { error: 'Not found' }) log(`Telephony: updated ${resource}/${ref}`) return json(res, 200, result.rows[0]) } // DELETE /telephony/{resource}/{ref} — Delete if (method === 'DELETE' && ref) { const result = await pool.query(`DELETE FROM ${tableName} WHERE ref = $1 RETURNING ref`, [ref]) if (result.rows.length === 0) return json(res, 404, { error: 'Not found' }) log(`Telephony: deleted ${resource}/${ref}`) return json(res, 200, { ok: true, deleted: ref }) } return json(res, 405, { error: 'Method not allowed' }) } catch (e) { log('Telephony error:', e.message) return json(res, 500, { error: e.message }) } } // ── 3CX Call Log Poller ── // Polls 3CX xAPI for recently completed calls, logs new ones to ERPNext + SSE let pbxToken = null let pbxTokenExpiry = 0 const processedCallIds = new Set() // Track already-processed call IDs (in memory, survives poll cycles) let pbxPollTimer = null function httpsFetch (url, opts = {}) { return new Promise((resolve, reject) => { const parsed = new URL(url) const reqOpts = { hostname: parsed.hostname, port: parsed.port || 443, path: parsed.pathname + parsed.search, method: opts.method || 'GET', headers: opts.headers || {}, } const req = https.request(reqOpts, (res) => { let body = '' res.on('data', c => body += c) res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(body) }) } catch { resolve({ status: res.statusCode, data: body }) } }) }) req.on('error', reject) if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) req.end() }) } async function get3cxToken () { if (pbxToken && Date.now() < pbxTokenExpiry - 60000) return pbxToken // 1min buffer if (!PBX_USER || !PBX_PASS) return null try { const res = await httpsFetch(PBX_URL + '/webclient/api/Login/GetAccessToken', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Username: PBX_USER, Password: PBX_PASS }), }) if (res.data.Status === 'AuthSuccess') { pbxToken = res.data.Token.access_token pbxTokenExpiry = Date.now() + (res.data.Token.expires_in * 1000) return pbxToken } log('3CX auth failed:', res.data.Status) } catch (e) { log('3CX auth error:', e.message) } return null } async function poll3cxCallLog () { const token = await get3cxToken() if (!token) return try { // Fetch last 20 calls from the last 5 minutes const since = new Date(Date.now() - 5 * 60 * 1000).toISOString() const url = `${PBX_URL}/xapi/v1/ReportCallLogData?$top=20&$orderby=StartTime%20desc&$filter=StartTime%20gt%20${encodeURIComponent(since)}` const res = await httpsFetch(url, { headers: { Authorization: 'Bearer ' + token }, }) if (res.status !== 200 || !res.data.value) return for (const call of res.data.value) { const callId = call.CdrId || call.CallHistoryId || '' if (!callId || processedCallIds.has(callId)) continue // Only process answered/completed calls (not segments still in progress) if (!call.TalkingDuration && !call.Answered) continue processedCallIds.add(callId) // Parse call data const direction = (call.Direction || '').toLowerCase().includes('inbound') ? 'in' : 'out' const isOutbound = direction === 'out' const sourcePhone = call.SourceCallerId || call.SourceDn || '' const destPhone = call.DestinationCallerId || call.DestinationDn || '' const remotePhone = isOutbound ? destPhone : sourcePhone const ext = isOutbound ? call.SourceDn : call.DestinationDn // Parse duration from ISO 8601 duration (PT1M30S) or seconds let durationSec = 0 const td = call.TalkingDuration || '' if (typeof td === 'string' && td.startsWith('PT')) { const minMatch = td.match(/(\d+)M/) const secMatch = td.match(/(\d+)S/) const hrMatch = td.match(/(\d+)H/) durationSec = (hrMatch ? parseInt(hrMatch[1]) * 3600 : 0) + (minMatch ? parseInt(minMatch[1]) * 60 : 0) + (secMatch ? parseInt(secMatch[1]) : 0) } else if (typeof td === 'number') { durationSec = td } // Skip very short/empty calls if (durationSec < 3 && !call.Answered) continue log(`3CX POLL: ${sourcePhone} → ${destPhone} dir=${direction} dur=${durationSec}s answered=${call.Answered} (${callId})`) // Lookup customer and log try { const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null const customerName = customer ? customer.name : null const customerLabel = customer ? customer.customer_name : 'Inconnu' // Log Communication in ERPNext if (customerName) { const durationMin = Math.floor(durationSec / 60) const durationS = durationSec % 60 const durationStr = `${durationMin}m${durationS.toString().padStart(2, '0')}s` await createCommunication({ communication_type: 'Communication', communication_medium: 'Phone', sent_or_received: isOutbound ? 'Sent' : 'Received', sender: 'sms@gigafibre.ca', sender_full_name: isOutbound ? (call.SourceDisplayName || 'Targo Ops') : customerLabel, phone_no: remotePhone, content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${durationStr}${call.Answered ? '' : ' (manque)'}`, subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`, reference_doctype: 'Customer', reference_name: customerName, status: 'Linked', }) log(`3CX logged to ERPNext: ${remotePhone} → ${customerName} (${durationStr})`) } // Broadcast SSE const eventData = { type: 'call', event: 'completed', direction, customer: customerName, customer_name: customerLabel, phone: remotePhone, extension: ext, duration: durationSec, answered: call.Answered, call_id: callId, ts: call.StartTime || new Date().toISOString(), } if (customerName) { broadcast('customer:' + customerName, 'call-event', eventData) } broadcastAll('call-event', eventData) } catch (e) { log('3CX poll processing error:', e.message) } } // Prune old processed IDs (keep last 500) if (processedCallIds.size > 500) { const arr = [...processedCallIds] arr.splice(0, arr.length - 500) processedCallIds.clear() arr.forEach(id => processedCallIds.add(id)) } } catch (e) { log('3CX poll error:', e.message) } } function start3cxPoller () { if (!PBX_ENABLED) { log('3CX poller: DISABLED (PBX_ENABLED != 1, using Twilio instead)') return } if (!PBX_USER || !PBX_PASS) { log('3CX poller: DISABLED (no PBX_USER/PBX_PASS)') return } log(`3CX poller: ENABLED (every ${PBX_POLL_INTERVAL / 1000}s) → ${PBX_URL}`) // Initial poll after 5s setTimeout(poll3cxCallLog, 5000) pbxPollTimer = setInterval(poll3cxCallLog, PBX_POLL_INTERVAL) } // ── GenieACS NBI API Proxy ── // Provides /devices endpoints for CPE/ONT management via GenieACS NBI (port 7557) function nbiRequest (nbiPath, method = 'GET', body = null, extraHeaders = {}) { return new Promise((resolve, reject) => { const u = new URL(nbiPath, GENIEACS_NBI_URL) const opts = { hostname: u.hostname, port: u.port || 7557, path: u.pathname + u.search, method, headers: { 'Content-Type': 'application/json', ...extraHeaders }, timeout: 10000, } const transport = u.protocol === 'https:' ? https : http const req = transport.request(opts, (resp) => { let data = '' resp.on('data', c => { data += c }) resp.on('end', () => { try { resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null }) } catch { resolve({ status: resp.statusCode, data: data }) } }) }) req.on('error', reject) req.on('timeout', () => { req.destroy(); reject(new Error('NBI timeout')) }) if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body)) req.end() }) } async function handleGenieACS (req, res, method, path, url) { try { if (!GENIEACS_NBI_URL) { return json(res, 503, { error: 'GenieACS NBI not configured' }) } const parts = path.replace('/devices', '').split('/').filter(Boolean) // parts: [] = list, [serial] = device detail, [serial, action] = action // GET /devices — List all devices (with optional query filter) if (parts.length === 0 && method === 'GET') { const q = url.searchParams.get('q') || '' const projection = url.searchParams.get('projection') || '' const limit = url.searchParams.get('limit') || '50' const skip = url.searchParams.get('skip') || '0' let nbiPath = `/devices/?limit=${limit}&skip=${skip}` if (q) nbiPath += '&query=' + encodeURIComponent(q) if (projection) nbiPath += '&projection=' + encodeURIComponent(projection) const r = await nbiRequest(nbiPath) return json(res, r.status, r.data) } // GET /devices/summary — Aggregated device stats if (parts[0] === 'summary' && method === 'GET') { // Get all devices with minimal projection for counting const r = await nbiRequest('/devices/?projection=_deviceId,_tags&limit=10000') if (r.status !== 200 || !Array.isArray(r.data)) { return json(res, r.status, r.data || { error: 'Failed to fetch devices' }) } const devices = r.data const stats = { total: devices.length, byManufacturer: {}, byProductClass: {}, byTag: {}, } for (const d of devices) { const did = d._deviceId || {} const mfr = did._Manufacturer || 'Unknown' const pc = did._ProductClass || 'Unknown' stats.byManufacturer[mfr] = (stats.byManufacturer[mfr] || 0) + 1 stats.byProductClass[pc] = (stats.byProductClass[pc] || 0) + 1 if (d._tags && Array.isArray(d._tags)) { for (const t of d._tags) { stats.byTag[t] = (stats.byTag[t] || 0) + 1 } } } return json(res, 200, stats) } // GET /devices/:id — Single device detail if (parts.length === 1 && method === 'GET') { const deviceId = decodeURIComponent(parts[0]) const q = JSON.stringify({ _id: deviceId }) const r = await nbiRequest('/devices/?query=' + encodeURIComponent(q)) if (r.status !== 200) return json(res, r.status, r.data) const devices = Array.isArray(r.data) ? r.data : [] if (!devices.length) return json(res, 404, { error: 'Device not found' }) return json(res, 200, devices[0]) } // POST /devices/:id/reboot — Reboot a CPE if (parts.length === 2 && parts[1] === 'reboot' && method === 'POST') { const deviceId = decodeURIComponent(parts[0]) const task = { name: 'reboot' } const r = await nbiRequest( '/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request', 'POST', task ) return json(res, r.status, r.data) } // POST /devices/:id/refresh — Force parameter refresh if (parts.length === 2 && parts[1] === 'refresh' && method === 'POST') { const deviceId = decodeURIComponent(parts[0]) const task = { name: 'getParameterValues', parameterNames: [ 'InternetGatewayDevice.', 'Device.', ], } const r = await nbiRequest( '/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request', 'POST', task ) return json(res, r.status, r.data) } // POST /devices/:id/set — Set parameter values if (parts.length === 2 && parts[1] === 'set' && method === 'POST') { const deviceId = decodeURIComponent(parts[0]) const body = await parseBody(req) const parsed = JSON.parse(body) // Expected: { parameterValues: [["path", "value", "type"], ...] } if (!parsed.parameterValues) { return json(res, 400, { error: 'Missing parameterValues array' }) } const task = { name: 'setParameterValues', parameterValues: parsed.parameterValues, } const r = await nbiRequest( '/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request', 'POST', task ) return json(res, r.status, r.data) } // DELETE /devices/:id/tasks/:taskId — Cancel a pending task if (parts.length === 3 && parts[1] === 'tasks' && method === 'DELETE') { const taskId = parts[2] const r = await nbiRequest('/tasks/' + taskId, 'DELETE') return json(res, r.status, r.data) } // GET /devices/:id/tasks — List tasks for a device if (parts.length === 2 && parts[1] === 'tasks' && method === 'GET') { const deviceId = decodeURIComponent(parts[0]) const q = JSON.stringify({ device: deviceId }) const r = await nbiRequest('/tasks/?query=' + encodeURIComponent(q)) return json(res, r.status, r.data) } // GET /devices/:id/faults — List faults for a device if (parts.length === 2 && parts[1] === 'faults' && method === 'GET') { const deviceId = decodeURIComponent(parts[0]) const q = JSON.stringify({ device: deviceId }) const r = await nbiRequest('/faults/?query=' + encodeURIComponent(q)) return json(res, r.status, r.data) } // POST /devices/:id/tag — Add a tag if (parts.length === 2 && parts[1] === 'tag' && method === 'POST') { const deviceId = decodeURIComponent(parts[0]) const body = await parseBody(req) const { tag } = JSON.parse(body) if (!tag) return json(res, 400, { error: 'Missing tag' }) const r = await nbiRequest( '/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag), 'POST' ) return json(res, r.status, r.data) } // DELETE /devices/:id/tag/:tag — Remove a tag if (parts.length === 3 && parts[1] === 'tag' && method === 'DELETE') { const deviceId = decodeURIComponent(parts[0]) const tag = decodeURIComponent(parts[2]) const r = await nbiRequest( '/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag), 'DELETE' ) return json(res, r.status, r.data) } // GET /devices/faults — List all faults if (parts[0] === 'faults' && parts.length === 1 && method === 'GET') { const r = await nbiRequest('/faults/') return json(res, r.status, r.data) } return json(res, 404, { error: 'Unknown device endpoint' }) } catch (e) { log('GenieACS error:', e.message) return json(res, 502, { error: 'GenieACS NBI error: ' + e.message }) } } // ── GenieACS Config Export ── // Extract provisions, presets, virtual parameters, files metadata from GenieACS NBI // Used for migration to Oktopus TR-369 and as backup of all ACS logic async function handleACSConfig (req, res, method, path, url) { if (!GENIEACS_NBI_URL) return json(res, 503, { error: 'GenieACS NBI not configured' }) try { const parts = path.replace('/acs/', '').split('/').filter(Boolean) const resource = parts[0] // GET /acs/provisions — list all provision scripts if (resource === 'provisions' && method === 'GET') { const r = await genieRequest('GET', '/provisions/') return json(res, r.status, r.data) } // GET /acs/provisions/:id — get single provision script if (resource === 'provisions' && parts[1] && method === 'GET') { const id = decodeURIComponent(parts[1]) const r = await genieRequest('GET', '/provisions/' + encodeURIComponent(id)) return json(res, r.status, r.data) } // GET /acs/presets — list all presets (trigger rules) if (resource === 'presets' && method === 'GET') { const r = await genieRequest('GET', '/presets/') return json(res, r.status, r.data) } // GET /acs/virtual-parameters — list all virtual parameters if (resource === 'virtual-parameters' && method === 'GET') { const r = await genieRequest('GET', '/virtual-parameters/') return json(res, r.status, r.data) } // GET /acs/files — list all files (firmware, configs) if (resource === 'files' && method === 'GET') { const r = await genieRequest('GET', '/files/') return json(res, r.status, r.data) } // GET /acs/faults — list all active faults if (resource === 'faults' && method === 'GET') { const r = await genieRequest('GET', '/faults/') return json(res, r.status, r.data) } // GET /acs/export — full export of all config (provisions + presets + virtual params + files metadata) if (resource === 'export' && method === 'GET') { const [provisions, presets, virtualParams, files, faults] = await Promise.all([ genieRequest('GET', '/provisions/').then(r => r.data).catch(() => []), genieRequest('GET', '/presets/').then(r => r.data).catch(() => []), genieRequest('GET', '/virtual-parameters/').then(r => r.data).catch(() => []), genieRequest('GET', '/files/').then(r => r.data).catch(() => []), genieRequest('GET', '/faults/').then(r => r.data).catch(() => []), ]) const exportData = { exportedAt: new Date().toISOString(), source: GENIEACS_NBI_URL, provisions: Array.isArray(provisions) ? provisions : [], presets: Array.isArray(presets) ? presets : [], virtualParameters: Array.isArray(virtualParams) ? virtualParams : [], files: Array.isArray(files) ? files.map(f => ({ _id: f._id, metadata: f.metadata || {}, filename: f.filename, length: f.length, uploadDate: f.uploadDate, })) : [], faultCount: Array.isArray(faults) ? faults.length : 0, summary: { provisionCount: Array.isArray(provisions) ? provisions.length : 0, presetCount: Array.isArray(presets) ? presets.length : 0, virtualParamCount: Array.isArray(virtualParams) ? virtualParams.length : 0, fileCount: Array.isArray(files) ? files.length : 0, }, } return json(res, 200, exportData) } return json(res, 404, { error: 'Unknown ACS config endpoint' }) } catch (e) { log('ACS config error:', e.message) return json(res, 502, { error: 'GenieACS config error: ' + e.message }) } } // ── GenieACS NBI Proxy ── // Proxies requests to the GenieACS NBI API for CPE/ONT device management // Endpoints: // GET /devices — List all devices (with optional query/projection) // GET /devices/:id — Get single device by _id // GET /devices/:id/tasks — Get pending tasks for device // POST /devices/:id/tasks?connection_request — Push task (reboot, getParameterValues, etc.) // DELETE /devices/:id — Delete device from ACS // GET /devices/summary — Aggregate device stats (online/offline counts by model) function genieRequest (method, path, body) { return new Promise((resolve, reject) => { const urlObj = new URL(path, GENIEACS_NBI_URL) const opts = { hostname: urlObj.hostname, port: urlObj.port, path: urlObj.pathname + urlObj.search, method, headers: { 'Content-Type': 'application/json' }, timeout: 15000, } const proto = urlObj.protocol === 'https:' ? https : http const req = proto.request(opts, (resp) => { let data = '' resp.on('data', chunk => { data += chunk }) resp.on('end', () => { try { resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null }) } catch { resolve({ status: resp.statusCode, data: data }) } }) }) req.on('error', reject) req.on('timeout', () => { req.destroy(); reject(new Error('GenieACS NBI timeout')) }) if (body) req.write(JSON.stringify(body)) req.end() }) } // Extract key parameters from a GenieACS device object into a flat summary function summarizeDevice (d) { const get = (path) => { const node = path.split('.').reduce((obj, k) => obj && obj[k], d) return node && node._value !== undefined ? node._value : (node && node._object !== undefined ? undefined : null) } const getStr = (path) => { const v = get(path) return v != null ? String(v) : '' } return { _id: d._id || d['_id'], serial: getStr('DeviceID.SerialNumber') || (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '', manufacturer: getStr('DeviceID.Manufacturer') || (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '', model: getStr('DeviceID.ProductClass') || (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '', oui: getStr('DeviceID.OUI') || (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '', firmware: getStr('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getStr('Device.DeviceInfo.SoftwareVersion') || '', uptime: get('InternetGatewayDevice.DeviceInfo.UpTime') || get('Device.DeviceInfo.UpTime') || null, lastInform: d['_lastInform'] || null, lastBoot: d['_lastBootstrap'] || null, registered: d['_registered'] || null, ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getStr('Device.IP.Interface.1.IPv4Address.1.IPAddress') || '', rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower') || get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower') || get('Device.Optical.Interface.1.Stats.SignalRxPower') || null, txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower') || get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower') || get('Device.Optical.Interface.1.Stats.SignalTxPower') || null, pppoeUser: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username') || getStr('Device.PPP.Interface.1.Username') || '', ssid: getStr('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID') || getStr('Device.WiFi.SSID.1.SSID') || '', macAddress: getStr('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress') || getStr('Device.Ethernet.Interface.1.MACAddress') || '', tags: d['_tags'] || [], } } async function handleGenieACS (req, res, method, path, url) { if (!GENIEACS_NBI_URL) { return json(res, 503, { error: 'GenieACS NBI not configured' }) } try { const parts = path.replace('/devices', '').split('/').filter(Boolean) // GET /devices/summary — aggregate stats if (parts[0] === 'summary' && method === 'GET') { const now = Date.now() const fiveMinAgo = new Date(now - 5 * 60 * 1000).toISOString() const result = await genieRequest('GET', `/devices/?projection=DeviceID,_lastInform,_tags&limit=10000`) const devices = Array.isArray(result.data) ? result.data : [] const stats = { total: devices.length, online: 0, offline: 0, models: {} } for (const d of devices) { const model = (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || 'Unknown' const lastInform = d._lastInform const isOnline = lastInform && new Date(lastInform).toISOString() > fiveMinAgo if (isOnline) stats.online++; else stats.offline++ if (!stats.models[model]) stats.models[model] = { total: 0, online: 0 } stats.models[model].total++ if (isOnline) stats.models[model].online++ } return json(res, 200, stats) } // GET /devices/lookup?serial=XXX — Find device by serial number if (parts[0] === 'lookup' && method === 'GET') { const serial = url.searchParams.get('serial') const mac = url.searchParams.get('mac') if (!serial && !mac) return json(res, 400, { error: 'Provide serial or mac parameter' }) let query = '' if (serial) query = `DeviceID.SerialNumber = "${serial}"` else if (mac) query = `InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress = "${mac}" OR Device.Ethernet.Interface.1.MACAddress = "${mac}"` const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`) const devices = Array.isArray(result.data) ? result.data : [] return json(res, 200, devices.map(summarizeDevice)) } // GET /devices — list devices (pass through query params) if (!parts.length && method === 'GET') { const limit = url.searchParams.get('limit') || '50' const skip = url.searchParams.get('skip') || '0' const query = url.searchParams.get('query') || '' const projection = url.searchParams.get('projection') || 'DeviceID,_lastInform,_tags' const sort = url.searchParams.get('sort') || '{"_lastInform":-1}' let nbiPath = `/devices/?projection=${encodeURIComponent(projection)}&limit=${limit}&skip=${skip}&sort=${encodeURIComponent(sort)}` if (query) nbiPath += `&query=${encodeURIComponent(query)}` const result = await genieRequest('GET', nbiPath) const devices = Array.isArray(result.data) ? result.data : [] // If projection is minimal, return raw; otherwise summarize if (projection === 'DeviceID,_lastInform,_tags') { return json(res, 200, devices.map(d => ({ _id: d._id, serial: (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '', manufacturer: (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '', model: (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '', oui: (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '', lastInform: d._lastInform || null, tags: d._tags || [], }))) } return json(res, 200, devices.map(summarizeDevice)) } // Decode device ID (URL-encoded, e.g., "202BC1-BM632w-ZTEGC8B042%2F02211") const deviceId = decodeURIComponent(parts[0]) const subResource = parts[1] || null // GET /devices/:id — single device detail if (!subResource && method === 'GET') { const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(`_id = "${deviceId}"`)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`) const devices = Array.isArray(result.data) ? result.data : [] if (!devices.length) return json(res, 404, { error: 'Device not found' }) return json(res, 200, summarizeDevice(devices[0])) } // POST /devices/:id/tasks — send task to device if (subResource === 'tasks' && method === 'POST') { const body = await parseBody(req) const connReq = url.searchParams.get('connection_request') !== null let nbiPath = `/devices/${encodeURIComponent(deviceId)}/tasks` if (connReq) nbiPath += '?connection_request' const timeout = url.searchParams.get('timeout') if (timeout) nbiPath += (connReq ? '&' : '?') + `timeout=${timeout}` const result = await genieRequest('POST', nbiPath, body) return json(res, result.status, result.data) } // GET /devices/:id/tasks — get pending tasks if (subResource === 'tasks' && method === 'GET') { const result = await genieRequest('GET', `/tasks/?query=${encodeURIComponent(`device = "${deviceId}"`)}`) return json(res, result.status, result.data) } // GET /devices/:id/faults — get device faults if (subResource === 'faults' && method === 'GET') { const result = await genieRequest('GET', `/faults/?query=${encodeURIComponent(`device = "${deviceId}"`)}`) return json(res, result.status, result.data) } // DELETE /devices/:id/tasks/:taskId — cancel a task if (subResource === 'tasks' && parts[2] && method === 'DELETE') { const result = await genieRequest('DELETE', `/tasks/${parts[2]}`) return json(res, result.status, result.data) } // DELETE /devices/:id — remove device from ACS if (!subResource && method === 'DELETE') { const result = await genieRequest('DELETE', `/devices/${encodeURIComponent(deviceId)}`) return json(res, result.status, result.data) } return json(res, 400, { error: 'Unknown device endpoint' }) } catch (e) { log('GenieACS error:', e.message) return json(res, 502, { error: 'GenieACS NBI error: ' + e.message }) } } server.listen(PORT, '0.0.0.0', () => { log(`targo-hub listening on :${PORT}`) log(` SSE: GET /sse?topics=customer:C-LPB4`) log(` Broadcast: POST /broadcast`) log(` SMS In: POST /webhook/twilio/sms-incoming`) log(` SMS Out: POST /send/sms`) log(` 3CX Call: POST /webhook/3cx/call-event`) log(` Voice Tkn: GET /voice/token`) log(` Voice TML: POST /voice/twiml`) log(` Voice Sts: POST /voice/status`) log(` Health: GET /health`) log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`) log(` Telephony: GET /telephony/overview`) log(` Devices: GET /devices, /devices/summary, /devices/lookup?serial=X`) log(` Devices: GET|POST|DELETE /devices/:id/tasks`) log(` GenieACS: ${GENIEACS_NBI_URL}`) log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`) // Start 3CX poller start3cxPoller() })