/** * 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() }) } // Convenience wrapper: erpRequest(method, path, body?) → { status, data } async function erpRequest (method, path, body) { const opts = { method } if (body) opts.body = body return erpFetch(path, opts) } 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) } // ─── Installation / Provisioning API ─── // OLT pre-authorization, equipment activation, barcode-triggered workflows if (path.startsWith('/provision/')) { return handleProvisioning(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() }) } // Find the WAN (public) IP by scanning all IP interfaces for a non-RFC1918 address function findWanIp (d) { const ifaces = extractAllInterfaces(d) const pub = ifaces.find(i => i.role === 'internet') return pub ? pub.ip : '' } // Extract all active IP interfaces with role classification function extractAllInterfaces (d) { const ipIfaces = d.Device && d.Device.IP && d.Device.IP.Interface if (!ipIfaces) return [] const results = [] for (const ifKey of Object.keys(ipIfaces)) { if (ifKey.startsWith('_')) continue const iface = ipIfaces[ifKey] if (!iface || !iface.IPv4Address) continue const name = iface.Name && iface.Name._value || '' for (const addrKey of Object.keys(iface.IPv4Address)) { if (addrKey.startsWith('_')) continue const addr = iface.IPv4Address[addrKey] if (!addr || !addr.IPAddress || !addr.IPAddress._value) continue const ip = addr.IPAddress._value const status = addr.Status && addr.Status._value if (!ip || ip === '0.0.0.0' || (status && status !== 'Enabled')) continue const mask = addr.SubnetMask && addr.SubnetMask._value || '' const addrType = addr.AddressingType && addr.AddressingType._value || '' // Classify by IP range let role = 'unknown' if (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) role = 'lan' else if (!ip.startsWith('10.') && !ip.startsWith('172.') && !ip.startsWith('192.168.') && !ip.startsWith('169.254.')) role = 'internet' else if (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10')) role = 'management' else role = 'service' results.push({ iface: ifKey, name, ip, mask, addrType, role }) } } return results } // Sum all WiFi AccessPoint associated device counts (controller only, flat AP tree) function countAllWifiClients (d) { const aps = d.Device && d.Device.WiFi && d.Device.WiFi.AccessPoint if (!aps) return { direct: 0, mesh: 0, total: 0, perAp: [] } let direct = 0, mesh = 0 const perAp = [] for (const k of Object.keys(aps)) { if (k.startsWith('_') || !aps[k]) continue const cnt = aps[k].AssociatedDeviceNumberOfEntries if (!cnt || cnt._value === undefined) continue const n = Number(cnt._value) || 0 const ssidRef = aps[k].SSIDReference && aps[k].SSIDReference._value || '' const ssidIdx = ssidRef.match(/SSID\.(\d+)/) const idx = ssidIdx ? parseInt(ssidIdx[1]) : parseInt(k) if (idx <= 8) { direct += n } else { mesh += n } if (n > 0) perAp.push({ ap: k, ssid: ssidRef, clients: n }) } return { direct, mesh, total: direct + mesh, perAp } } // Extract EasyMesh topology from MultiAP tree — returns mesh nodes + true client totals function extractMeshTopology (d) { const apDevices = d.Device && d.Device.WiFi && d.Device.WiFi.MultiAP && d.Device.WiFi.MultiAP.APDevice if (!apDevices) return null const nodes = [] let totalClients = 0 for (const dk of Object.keys(apDevices)) { if (dk.startsWith('_')) continue const dev = apDevices[dk] if (!dev || !dev._object) continue const name = dev.X_TP_HostName && dev.X_TP_HostName._value || '' const active = dev.X_TP_Active && dev.X_TP_Active._value const mac = dev.MACAddress && dev.MACAddress._value || '' const ip = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || '' // Sum clients across all radios and APs for this node let nodeClients = 0 const radios = dev.Radio || {} for (const rk of Object.keys(radios)) { if (rk.startsWith('_')) continue const radio = radios[rk] if (!radio || !radio.AP) continue for (const ak of Object.keys(radio.AP)) { if (ak.startsWith('_')) continue const ap = radio.AP[ak] if (!ap) continue const cnt = ap.AssociatedDeviceNumberOfEntries if (cnt && cnt._value !== undefined) nodeClients += Number(cnt._value) || 0 } } totalClients += nodeClients if (name || mac) { nodes.push({ id: dk, name, active: active === true, mac, ip, clients: nodeClients }) } } return nodes.length > 0 ? { nodes, totalClients } : null } // 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, interfaces: extractAllInterfaces(d), ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || findWanIp(d) || '', rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower') || get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower') || get('Device.Optical.Interface.1.Stats.SignalRxPower') || get('Device.Optical.Interface.1.OpticalSignalLevel') || null, txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower') || get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower') || get('Device.Optical.Interface.1.Stats.SignalTxPower') || get('Device.Optical.Interface.1.TransmitOpticalLevel') || 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'] || [], // ── Extended diagnostics (XX230v / TR-181 devices) ── opticalStatus: getStr('Device.Optical.Interface.1.Status') || null, opticalErrors: { sent: get('Device.Optical.Interface.1.Stats.ErrorsSent') || 0, received: get('Device.Optical.Interface.1.Stats.ErrorsReceived') || 0, }, wifi: (() => { const counts = countAllWifiClients(d) const mesh = extractMeshTopology(d) return { radio1: { status: getStr('Device.WiFi.Radio.1.Status') || null, channel: get('Device.WiFi.Radio.1.Channel') || null, bandwidth: getStr('Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth') || null, noise: get('Device.WiFi.Radio.1.Stats.Noise') || null, clients: get('Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries') || 0, }, radio2: { status: getStr('Device.WiFi.Radio.2.Status') || null, channel: get('Device.WiFi.Radio.2.Channel') || null, bandwidth: getStr('Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth') || null, noise: get('Device.WiFi.Radio.2.Stats.Noise') || null, clients: get('Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries') || 0, }, radio3: { status: getStr('Device.WiFi.Radio.3.Status') || null, channel: get('Device.WiFi.Radio.3.Channel') || null, clients: get('Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries') || 0, }, // Use mesh totals if available (more accurate), fall back to flat AP counts totalClients: mesh ? mesh.totalClients : counts.total, directClients: counts.direct, meshClients: mesh ? (mesh.totalClients - counts.direct) : counts.mesh, } })(), // EasyMesh topology — each node with name, IP, client count mesh: (() => { const mesh = extractMeshTopology(d) return mesh ? mesh.nodes : null })(), hostsCount: get('Device.Hosts.HostNumberOfEntries') || null, ethernet: { port1: { status: getStr('Device.Ethernet.Interface.1.Status') || null, speed: get('Device.Ethernet.Interface.1.MaxBitRate') || null }, port2: { status: getStr('Device.Ethernet.Interface.2.Status') || null, speed: get('Device.Ethernet.Interface.2.MaxBitRate') || null }, }, } } 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' }) const projection = 'DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags' let devices = [] if (serial) { // Try 1: exact SerialNumber match (works for Raisecom HT803G etc.) let query = JSON.stringify({ 'DeviceID.SerialNumber._value': serial }) let result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`) devices = Array.isArray(result.data) ? result.data : [] // Try 2: GPON serial match (XX230v stores TPLG + last 8 hex of GponSn as serial in ERPNext) if (!devices.length && serial.startsWith('TPLG')) { const gponSuffix = serial.slice(4).toUpperCase() query = JSON.stringify({ 'Device.Optical.Interface.1.GponAuth.GponSn._value': { '$regex': gponSuffix + '$' } }) result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`) devices = Array.isArray(result.data) ? result.data : [] } // Try 3: _id contains the serial (fallback) if (!devices.length) { query = JSON.stringify({ '_id': { '$regex': serial } }) result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`) devices = Array.isArray(result.data) ? result.data : [] } } else if (mac) { const cleanMac = mac.replace(/[:-]/g, '').toUpperCase() const query = JSON.stringify({ '$or': [ { 'InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress._value': mac }, { 'Device.Ethernet.Interface.1.MACAddress._value': mac }, { 'Device.DeviceInfo.X_TP_MACAddress._value': { '$regex': cleanMac.slice(-6) } }, ]}) const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`) 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/hosts — get connected clients (LAN hosts + DHCP leases) if (subResource === 'hosts' && method === 'GET') { const encId = encodeURIComponent(deviceId) // Step 1: Send getParameterValues for hosts — triggers connection_request + provision const hostParams = [] for (let i = 1; i <= 20; i++) { for (const f of ['HostName', 'IPAddress', 'PhysAddress', 'Active', 'AddressSource', 'LeaseTimeRemaining', 'Layer1Interface']) { hostParams.push(`Device.Hosts.Host.${i}.${f}`) } } hostParams.push('Device.Hosts.HostNumberOfEntries') try { await genieRequest('POST', `/devices/${encId}/tasks?connection_request&timeout=15000`, { name: 'getParameterValues', parameterNames: hostParams }) } catch (e) { /* timeout ok */ } // Step 2: Now send a SECOND task for MultiAP associated device MACs // The device session is still open from step 1, so this runs immediately // This reads which client MAC is connected to which mesh node AP const multiApParams = [] for (let d2 = 1; d2 <= 3; d2++) { // up to 3 mesh nodes for (let r = 1; r <= 2; r++) { // 2 radios per node for (let a = 1; a <= 4; a++) { // up to 4 APs per radio multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDeviceNumberOfEntries`) for (let c = 1; c <= 8; c++) { // up to 8 clients per AP multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.MACAddress`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.SignalStrength`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.X_TP_NegotiationSpeed`) } } } // Also fetch node info multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_HostName`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.MACAddress`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_Active`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.1.OperatingFrequencyBand`) multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.2.OperatingFrequencyBand`) } try { await genieRequest('POST', `/devices/${encId}/tasks?timeout=10000`, { name: 'getParameterValues', parameterNames: multiApParams }) } catch (e) { /* timeout ok */ } // Step 3: Read the now-cached data from GenieACS const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(JSON.stringify({ _id: deviceId }))}&projection=Device.Hosts,Device.WiFi.MultiAP`) const devices = Array.isArray(result.data) ? result.data : [] if (!devices.length) return json(res, 404, { error: 'Device not found' }) const dd = devices[0] const hostTree = dd.Device && dd.Device.Hosts && dd.Device.Hosts.Host || {} const hostCount = dd.Device && dd.Device.Hosts && dd.Device.Hosts.HostNumberOfEntries && dd.Device.Hosts.HostNumberOfEntries._value || 0 // Step 4: Build client MAC → { nodeName, band, signal, speed } from MultiAP const clientNodeMap = new Map() // client MAC (upper) → { nodeName, band, signal, speed } const meshNodes = new Map() // node MAC (upper) → { name, ip } const apDevices = dd.Device && dd.Device.WiFi && dd.Device.WiFi.MultiAP && dd.Device.WiFi.MultiAP.APDevice || {} for (const dk of Object.keys(apDevices)) { if (dk.startsWith('_')) continue const dev = apDevices[dk] const nodeMac = dev.MACAddress && dev.MACAddress._value || '' const nodeName = dev.X_TP_HostName && dev.X_TP_HostName._value || '' const nodeIp = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || '' if (nodeMac) meshNodes.set(nodeMac.toUpperCase(), { name: nodeName, ip: nodeIp }) // Walk radios → APs → AssociatedDevices const radios = dev.Radio || {} for (const rk of Object.keys(radios)) { if (rk.startsWith('_')) continue const radio = radios[rk] if (!radio) continue const freqBand = radio.OperatingFrequencyBand && radio.OperatingFrequencyBand._value || '' const band = freqBand.includes('5') ? '5GHz' : freqBand.includes('2.4') ? '2.4GHz' : freqBand || '' const aps = radio.AP || {} for (const ak of Object.keys(aps)) { if (ak.startsWith('_')) continue const ap = aps[ak] if (!ap) continue const assocDevs = ap.AssociatedDevice || {} for (const ck of Object.keys(assocDevs)) { if (ck.startsWith('_')) continue const client = assocDevs[ck] if (!client) continue const cMac = client.MACAddress && client.MACAddress._value if (!cMac) continue const signal = client.SignalStrength && client.SignalStrength._value const speed = client.X_TP_NegotiationSpeed && client.X_TP_NegotiationSpeed._value clientNodeMap.set(cMac.toUpperCase(), { nodeName, nodeMac: nodeMac.toUpperCase(), band, signal: signal != null ? Number(signal) : null, speed: speed != null ? String(speed) : '', }) } } } } // Step 5: Build host list, enriched with node mapping const hosts = [] for (const k of Object.keys(hostTree)) { if (k.startsWith('_')) continue const h = hostTree[k] if (!h || h._object === undefined) continue const gv = (key) => h[key] && h[key]._value !== undefined ? h[key]._value : null const name = gv('HostName') const ip = gv('IPAddress') const mac = gv('PhysAddress') if (!name && !ip && !mac) continue const active = gv('Active') const source = gv('AddressSource') const lease = gv('LeaseTimeRemaining') const isMeshNode = mac && meshNodes.has(mac.toUpperCase()) // Look up client → node mapping from MultiAP const nodeInfo = mac ? clientNodeMap.get(mac.toUpperCase()) : null const attachedNode = nodeInfo ? nodeInfo.nodeName : '' const band = nodeInfo ? nodeInfo.band : '' const signal = nodeInfo ? nodeInfo.signal : null const linkRate = nodeInfo ? nodeInfo.speed : '' let connType = 'wifi' if (isMeshNode) connType = 'mesh-node' else if (!nodeInfo && !active) connType = 'unknown' hosts.push({ id: k, name: name || '', ip: ip || '', mac: mac || '', active: active === true, addressSource: source || '', leaseRemaining: lease != null ? Number(lease) : null, connType, band, signal, linkRate, attachedNode, attachedMac: nodeInfo ? nodeInfo.nodeMac : '', isMeshNode, }) } hosts.sort((a, b) => { if (a.isMeshNode !== b.isMeshNode) return a.isMeshNode ? 1 : -1 if (a.active !== b.active) return a.active ? -1 : 1 return (a.name || a.ip).localeCompare(b.name || b.ip) }) return json(res, 200, { total: hostCount, hosts }) } // 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 }) } } // ═══════════════════════════════════════════════════════════════════════ // Provisioning API — OLT pre-auth, equipment activation, swap // ═══════════════════════════════════════════════════════════════════════ // // These endpoints are called by: // - n8n webhooks (on task open/close) // - Field tech app (on barcode scan) // - Ops app (manual activation) // // OLT pre-authorization flow: // When we know the address→OLT→slot→port mapping AND the ONT GPON serial // (scanned in warehouse or on-site), we can pre-register the ONT on the OLT // so it auto-activates the moment the tech plugs the fibre in. // // For XX230v: GPON serial = "TPLG" + last 8 chars of Device.Optical.Interface.1.GponAuth.GponSn // For Raisecom: GPON serial = RCMG sticker serial // // Equipment swap flow: // Tech reports defective device → scans new replacement device → system: // 1. Marks old equipment as "Défectueux" // 2. Creates new Service Equipment with scanned serial // 3. Transfers all provisioning data (WiFi, VoIP, OLT port) to new device // 4. If OLT-connected: de-registers old ONT, registers new one // 5. ACS pushes config to new device on bootstrap // async function handleProvisioning (req, res, method, path, url) { try { const parts = path.replace('/provision/', '').split('/').filter(Boolean) const action = parts[0] // ─── POST /provision/pre-authorize ─── // Pre-register an ONT on the OLT before the tech goes on site // Body: { gpon_serial, olt_ip, frame, slot, port, ont_id, vlans, equipment_name } if (action === 'pre-authorize' && method === 'POST') { const body = await parseBody(req) const { gpon_serial, olt_ip, frame, slot, port, ont_id, equipment_name } = body const vlans = body.vlans || {} if (!gpon_serial || !olt_ip || frame === undefined || slot === undefined || port === undefined) { return json(res, 400, { error: 'Missing required fields: gpon_serial, olt_ip, frame, slot, port' }) } // Build OLT CLI commands for Huawei/ZTE-style OLTs // These would be executed via SSH in a real implementation (or via n8n SSH node) const commands = [ `# OLT: ${olt_ip} — Pre-authorize ONT`, `interface gpon ${frame}/${slot}`, `ont add ${port} sn-auth "${gpon_serial}" omci ont-lineprofile-id ${body.line_profile || 10} ont-srvprofile-id ${body.service_profile || 10} desc "pre-auth-${equipment_name || 'unknown'}"`, `ont confirm ${port} ont ${ont_id}`, `quit`, `# Service ports (VLANs)`, vlans.internet ? `service-port auto vlan ${vlans.internet} gemport 1 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no internet vlan', vlans.manage ? `service-port auto vlan ${vlans.manage} gemport 2 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no manage vlan', vlans.telephone ? `service-port auto vlan ${vlans.telephone} gemport 3 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no telephone vlan', vlans.tv ? `service-port auto vlan ${vlans.tv} gemport 4 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no tv vlan', ] // Update ERPNext Service Equipment with GPON serial if (equipment_name) { try { await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, { gpon_serial: gpon_serial, status: 'Actif', }) } catch (e) { log('ERPNext update error:', e.message) } } // In production, this would SSH to the OLT and execute commands. // For now, return the commands for n8n to execute via SSH node. log(`[provision] Pre-authorize ${gpon_serial} on ${olt_ip} ${frame}/${slot}/${port} ONT:${ont_id}`) return json(res, 200, { status: 'commands_generated', gpon_serial, olt_ip, port: `${frame}/${slot}/${port}`, ont_id, commands, note: 'Execute via n8n SSH node or manual SSH to OLT', }) } // ─── POST /provision/on-scan ─── // Called when tech scans a barcode in the field app // Triggers: equipment update + OLT pre-auth if address→OLT mapping exists // Body: { serial, mac, equipment_type, equipment_name, customer, service_location, job_name } if (action === 'on-scan' && method === 'POST') { const body = await parseBody(req) const { serial, equipment_name, service_location } = body if (!serial) { return json(res, 400, { error: 'Missing serial' }) } const result = { serial, actions: [] } // 1. Update equipment serial if placeholder if (equipment_name) { try { await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, { serial_number: serial, mac_address: body.mac || null, status: 'Actif', }) result.actions.push({ action: 'equipment_updated', equipment_name, serial }) } catch (e) { result.actions.push({ action: 'equipment_update_failed', error: e.message }) } } // 2. Check if we should trigger OLT pre-auth // (if this is an ONT and we have the OLT port mapping from the Service Location) if (service_location && (body.equipment_type === 'ONT' || serial.startsWith('TPLG') || serial.startsWith('RCMG'))) { try { const locRes = await erpRequest('GET', `/api/resource/Service Location/${encodeURIComponent(service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`) const loc = locRes.data || {} if (loc.olt_ip && loc.olt_port) { // Parse olt_port: "0/3/2 ONT:12" → frame=0, slot=3, port=2 const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/) if (portMatch) { // Derive GPON serial from scanned serial let gpon_serial = serial // XX230v: physical serial IS the GPON serial (TPLGXXXXXXXX) // Raisecom: physical serial IS the GPON serial (RCMGXXXXXXXX) result.olt_pre_auth = { gpon_serial, olt_ip: loc.olt_ip, frame: parseInt(portMatch[1]), slot: parseInt(portMatch[2]), port: parseInt(portMatch[3]), ont_id: loc.ont_id || 0, vlans: { internet: loc.vlan_internet, manage: loc.vlan_manage, telephone: loc.vlan_telephone, tv: loc.vlan_tv, }, } result.actions.push({ action: 'olt_pre_auth_ready', message: 'OLT commands generated, send to n8n for SSH execution' }) } } } catch (e) { result.actions.push({ action: 'location_lookup_failed', error: e.message }) } } // 3. Broadcast SSE event so Ops sees the scan in real-time if (body.customer) { broadcast('customer:' + body.customer, 'equipment-scanned', { serial, equipment_type: body.equipment_type, job: body.job_name, }) } log(`[provision] Scan: ${serial} → ${result.actions.length} actions`) return json(res, 200, result) } // ─── POST /provision/swap ─── // Equipment swap: defective device → replacement OR diagnostic swap // Body: { old_equipment_name, new_serial, new_mac, equipment_type, swap_type, reason, customer, service_location, job_name } // swap_type: "permanent" (default) | "diagnostic" | "upgrade" // - permanent: old → Défectueux, confirmed dead // - diagnostic: old → En diagnostic, testing if it's the cause // - upgrade: old → Retourné, replaced with newer model if (action === 'swap' && method === 'POST') { const body = await parseBody(req) const { old_equipment_name, new_serial, equipment_type, customer, service_location } = body const swapType = body.swap_type || 'permanent' if (!old_equipment_name || !new_serial) { return json(res, 400, { error: 'Missing old_equipment_name and new_serial' }) } const result = { old_equipment: old_equipment_name, new_serial, swap_type: swapType, actions: [] } // Status mapping based on swap type const oldStatusMap = { permanent: 'Défectueux', diagnostic: 'En diagnostic', upgrade: 'Retourné', } const oldStatus = oldStatusMap[swapType] || 'Défectueux' // 1. Get old equipment data (to transfer provisioning info) let oldEquip = {} try { const r = await erpRequest('GET', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`) oldEquip = (r.data && r.data.data) || r.data || {} } catch (e) { return json(res, 404, { error: 'Old equipment not found: ' + old_equipment_name }) } // 2. Mark old equipment with appropriate status try { await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`, { status: oldStatus, notes: `${oldEquip.notes || ''}\n[${new Date().toISOString()}] ${swapType === 'diagnostic' ? 'Swap diagnostic' : 'Remplacé'} par ${new_serial}. Raison: ${body.reason || swapType}`.trim(), }) result.actions.push({ action: 'old_status_updated', name: old_equipment_name, status: oldStatus }) } catch (e) { result.actions.push({ action: 'status_update_failed', error: e.message }) } // 3. Create new equipment with transferred provisioning data const newName = 'EQ-SWAP-' + new_serial.substring(0, 10).replace(/[^A-Za-z0-9]/g, '') try { await erpRequest('POST', '/api/resource/Service Equipment', { name: newName, equipment_type: equipment_type || oldEquip.equipment_type, serial_number: new_serial, mac_address: body.new_mac || null, brand: oldEquip.brand, model: oldEquip.model, customer: customer || oldEquip.customer, service_location: service_location || oldEquip.service_location, subscription: oldEquip.subscription, status: 'Actif', ownership: 'Gigafibre', // Transfer provisioning data wifi_ssid: oldEquip.wifi_ssid, wifi_password: oldEquip.wifi_password, sip_username: oldEquip.sip_username, sip_password: oldEquip.sip_password, fibre_line_profile: oldEquip.fibre_line_profile, fibre_service_profile: oldEquip.fibre_service_profile, parent_device: oldEquip.parent_device, notes: `Remplacement de ${old_equipment_name} (${oldEquip.serial_number || 'N/A'}). Raison: ${body.reason || 'défectueux'}`, }) result.actions.push({ action: 'new_equipment_created', name: newName, serial: new_serial }) } catch (e) { result.actions.push({ action: 'create_new_failed', error: e.message }) } // 4. If ONT, prepare OLT swap commands (de-register old, register new) if ((equipment_type || oldEquip.equipment_type) === 'ONT' && service_location) { try { const locRes = await erpRequest('GET', `/api/resource/Service Location/${encodeURIComponent(service_location || oldEquip.service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`) const loc = locRes.data || {} const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/) if (loc.olt_ip && portMatch) { result.olt_swap_commands = [ `# OLT: ${loc.olt_ip} — Swap ONT on ${portMatch[0]}`, `interface gpon ${portMatch[1]}/${portMatch[2]}`, `# Remove old ONT`, `ont delete ${portMatch[3]} ${loc.ont_id || 0}`, `# Register new ONT`, `ont add ${portMatch[3]} sn-auth "${new_serial}" omci ont-lineprofile-id ${oldEquip.fibre_line_profile || 10} ont-srvprofile-id ${oldEquip.fibre_service_profile || 10} desc "swap-${newName}"`, `ont confirm ${portMatch[3]} ont ${loc.ont_id || 0}`, `quit`, `# Re-create service ports (same VLANs as before)`, loc.vlan_internet ? `service-port auto vlan ${loc.vlan_internet} gemport 1 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '', loc.vlan_manage ? `service-port auto vlan ${loc.vlan_manage} gemport 2 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '', ].filter(Boolean) result.actions.push({ action: 'olt_swap_commands_ready', message: 'Execute via n8n SSH node' }) } } catch (e) { result.actions.push({ action: 'olt_swap_lookup_failed', error: e.message }) } } // 5. Broadcast SSE event if (customer || oldEquip.customer) { broadcast('customer:' + (customer || oldEquip.customer), 'equipment-swapped', { old: old_equipment_name, new_serial, reason: body.reason, }) } log(`[provision] Swap: ${old_equipment_name} → ${new_serial} (${result.actions.length} actions)`) return json(res, 200, result) } // ─── GET /provision/equipment/:serial ─── // Lookup equipment by serial for provisioning data (called by ACS on bootstrap) if (parts.length >= 2 && parts[0] === 'equipment' && method === 'GET') { const serial = decodeURIComponent(parts[1]) try { const r = await erpRequest('GET', `/api/resource/Service Equipment?filters=[["serial_number","=","${serial}"]]&fields=["name","serial_number","mac_address","wifi_ssid","wifi_password","sip_username","sip_password","fibre_line_profile","fibre_service_profile","customer","service_location","equipment_type"]&limit_page_length=1`) const equips = (r.data || []) if (!equips.length) { return json(res, 404, { error: 'Equipment not found for serial: ' + serial }) } return json(res, 200, equips[0]) } catch (e) { return json(res, 502, { error: 'ERPNext lookup failed: ' + e.message }) } } return json(res, 400, { error: 'Unknown provision endpoint: ' + action }) } catch (e) { log('Provisioning error:', e.message) return json(res, 500, { error: 'Provisioning 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(` Provision: POST /provision/pre-authorize, /provision/on-scan, /provision/swap`) log(` Provision: GET /provision/equipment/:serial`) log(` GenieACS: ${GENIEACS_NBI_URL}`) log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`) // Start 3CX poller start3cxPoller() })