gigafibre-fsm/services/targo-hub/server.js
louispaulb ea71eec194 feat: GenieACS NBI integration for live CPE/ONT status
targo-hub:
- Add /devices/* endpoints proxying GenieACS NBI API (port 7557)
- /devices/summary — fleet stats (online/offline by model)
- /devices/lookup?serial=X — find device by serial number
- /devices/:id — device detail with summarized parameters
- /devices/:id/tasks — send reboot, getParameterValues, refresh
- /devices/:id/faults — device fault history
- GENIEACS_NBI_URL configurable via env var

ops app:
- New useDeviceStatus composable for live ACS status
- Equipment chips show green/red online dot from GenieACS
- Enriched tooltips: firmware, WAN IP, Rx/Tx power, SSID, last inform
- Right-click context menu: Reboot device, Refresh parameters
- Signal quality color coding (Rx power dBm thresholds)
- 1-minute client-side cache to avoid hammering NBI API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:55:13 -04:00

1470 lines
55 KiB
JavaScript

/**
* 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<topic, Set<{ res, email }>>
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('<Response></Response>')
// 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('<Response></Response>')
// 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 = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="${callerId}" answerOnBridge="true" timeout="30">
<Number statusCallbackEvent="initiated ringing answered completed"
statusCallback="${(process.env.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca')}/voice/status"
statusCallbackMethod="POST">${to}</Number>
</Dial>
</Response>`
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('<Response></Response>')
// 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)
}
// ─── 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 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()
})