- EquipmentDetail: collapsible node groups (clients grouped by mesh node) - Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale - Management IP clickable link to device web GUI (/superadmin/) - Fibre status compact top bar (status + Rx/Tx power when available) - targo-hub: WAN IP detection across all VLAN interfaces - targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters) - targo-hub: /devices/:id/hosts endpoint with client-to-node mapping - ClientsPage: start empty, load only on search (no auto-load all) - nginx: dynamic ollama resolver (won't crash if ollama is down) - Cleanup: remove unused BillingKPIs.vue and TagInput.vue - New docs and migration scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2198 lines
89 KiB
JavaScript
2198 lines
89 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()
|
|
})
|
|
}
|
|
|
|
// 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('<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)
|
|
}
|
|
|
|
// ─── 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()
|
|
})
|