gigafibre-fsm/services/targo-hub/lib/oktopus.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

552 lines
20 KiB
JavaScript

'use strict'
/**
* Oktopus TR-369/USP Integration
*
* Handles device pre-authorization and service provisioning via the
* Oktopus controller API at oss.gigafibre.ca.
*
* Device identity in Oktopus = MAC address (no colons, uppercase).
* ERPNext Service Equipment stores mac_address (with colons) and serial_number.
*
* Flow:
* 1. On equipment scan (field app) or bulk sync, create device auth in Oktopus
* 2. Attach a "registration" service instance so the CPE auto-provisions on BOOTSTRAP
* 3. Optionally push OLT/fibre config variables for ZTP
*/
const { log, httpRequest, erpFetch } = require('./helpers')
const cfg = require('./config')
// Internal Docker network URL (bypasses Authentik SSO proxy)
// Internal Docker network URL (bypasses Authentik SSO proxy)
const OKTOPUS_URL = cfg.OKTOPUS_URL || 'http://oktopus-controller-1:8000'
const OKTOPUS_USER = cfg.OKTOPUS_USER || ''
const OKTOPUS_PASS = cfg.OKTOPUS_PASS || ''
const OKTOPUS_MONGO_URL = cfg.OKTOPUS_MONGO_URL || 'mongodb://oktopus-mongo-1:27017'
let authToken = null
let tokenExpiry = 0
let mongoClient = null
let mongoDb = null
/* ── Auth ─────────────────────────────────────────────────────────── */
async function authenticate () {
if (authToken && Date.now() < tokenExpiry) return authToken
try {
const res = await httpRequest(OKTOPUS_URL, '/api/auth/login', {
method: 'PUT',
body: { email: OKTOPUS_USER, password: OKTOPUS_PASS },
})
// Oktopus returns JWT as a plain JSON string (with quotes)
const token = typeof res.data === 'string' ? res.data.replace(/^"|"$/g, '') : res.data?.token
if (res.status === 200 && token) {
authToken = token
// Refresh 5 min before expiry (assume 1h token)
tokenExpiry = Date.now() + 55 * 60 * 1000
log('[oktopus] Authenticated')
return authToken
}
log('[oktopus] Auth failed:', res.status, JSON.stringify(res.data))
return null
} catch (e) {
log('[oktopus] Auth error:', e.message)
return null
}
}
async function oktopusRequest (method, path, body = null) {
const token = await authenticate()
if (!token) throw new Error('Oktopus authentication failed')
return httpRequest(OKTOPUS_URL, path, {
method,
body,
// Oktopus expects raw JWT token (no "Bearer " prefix)
headers: { Authorization: token },
timeout: 15000,
})
}
/* ── MongoDB (Oktopus adapter DB — device records) ────────────────── */
async function getMongoDb () {
if (mongoDb) return mongoDb
try {
const { MongoClient } = require('mongodb')
mongoClient = new MongoClient(OKTOPUS_MONGO_URL)
await mongoClient.connect()
mongoDb = mongoClient.db('adapter')
log('[oktopus] Connected to Oktopus MongoDB')
return mongoDb
} catch (e) {
log('[oktopus] MongoDB connect error:', e.message)
return null
}
}
/**
* Ensure a device record exists in Oktopus MongoDB (adapter.devices).
* The adapter requires the device to exist before it can process status updates.
*/
async function ensureDeviceRecord (endpointId, opts = {}) {
const db = await getMongoDb()
if (!db) return { ok: false, error: 'MongoDB unavailable' }
try {
const col = db.collection('devices')
const existing = await col.findOne({ sn: endpointId })
if (existing) {
// Update if we have new info
if (opts.vendor || opts.model || opts.customer || opts.alias) {
const update = {}
if (opts.vendor) update.vendor = opts.vendor
if (opts.model) update.model = opts.model
if (opts.customer) update.customer = opts.customer
if (opts.alias) update.alias = opts.alias
await col.updateOne({ sn: endpointId }, { $set: update })
}
return { ok: true, existing: true }
}
await col.insertOne({
sn: endpointId,
model: opts.model || '',
customer: opts.customer || '',
vendor: opts.vendor || '',
version: '',
product_class: opts.product_class || 'ONT',
alias: opts.alias || '',
status: 0,
mqtt: true,
stomp: false,
websockets: false,
cwmp: false,
})
log(`[oktopus] Device record created: ${endpointId}`)
return { ok: true, created: true }
} catch (e) {
log(`[oktopus] MongoDB error for ${endpointId}:`, e.message)
return { ok: false, error: e.message }
}
}
/* ── MAC helpers ──────────────────────────────────────────────────── */
/** Normalize MAC to uppercase hex without separators: E4FAC4160688 */
function normalizeMac (mac) {
if (!mac) return null
return mac.replace(/[:\-. ]/g, '').toUpperCase()
}
/** Format as USP Agent Endpoint ID: ops::OUI-ProductClass-SerialNumber (TR-369 ops authority) */
function generateEndpointId (mac, serial, productClass = 'Device2') {
const cleanMac = normalizeMac(mac)
if (!cleanMac) return null
const oui = cleanMac.substring(0, 6)
if (serial) {
return `ops::${oui}-${productClass}-${serial}`
}
// Fallback if serial is missing
return `USP::${cleanMac}`
}
/** Generate a random password (alphanumeric, 16 chars) */
function generatePassword (len = 16) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
let result = ''
for (let i = 0; i < len; i++) result += chars[Math.floor(Math.random() * chars.length)]
return result
}
/* ── Device Auth (pre-authorization) ──────────────────────────────── */
/**
* Create device credentials in Oktopus so the CPE can authenticate via MQTT.
* @param {string} mac - MAC address (any format)
* @returns {{ ok: boolean, deviceId: string, password?: string, error?: string }}
*/
async function preAuthorizeDevice (mac) {
const deviceId = normalizeMac(mac)
if (!deviceId) return { ok: false, error: 'Invalid MAC address' }
const password = generatePassword()
try {
const res = await oktopusRequest('POST', '/api/device/auth', {
id: deviceId,
password,
})
if (res.status === 200 || res.status === 201) {
log(`[oktopus] Device pre-authorized: ${deviceId}`)
return { ok: true, deviceId, password }
}
if (res.status === 409) {
// Already exists — that's fine
log(`[oktopus] Device already registered: ${deviceId}`)
return { ok: true, deviceId, password: null, existing: true }
}
log(`[oktopus] Pre-auth failed: ${res.status}`, JSON.stringify(res.data))
return { ok: false, deviceId, error: `HTTP ${res.status}: ${JSON.stringify(res.data)}` }
} catch (e) {
log(`[oktopus] Pre-auth error for ${deviceId}:`, e.message)
return { ok: false, deviceId, error: e.message }
}
}
/* ── Full provisioning: pre-auth + fibre context ─────────────────── */
/**
* Pre-authorize a device and enrich with fibre/OLT context from ERPNext.
* Called during field scan (on-scan) or bulk sync.
*
* @param {object} opts
* @param {string} opts.mac - MAC address
* @param {string} opts.serial - ERPNext serial number
* @param {string} [opts.service_location] - ERPNext Service Location name
* @param {string} [opts.equipment_name] - ERPNext Service Equipment name
* @param {string} [opts.customer] - ERPNext Customer name
* @returns {object} Result with pre-auth status and fibre context
*/
async function provisionDevice (opts) {
const { mac, serial, service_location, equipment_name, customer } = opts
const result = {
serial,
mac,
deviceId: normalizeMac(mac),
endpointId: generateEndpointId(mac, serial),
actions: [],
}
// Step 1: Pre-authorize in Oktopus
const authResult = await preAuthorizeDevice(mac)
result.oktopus_auth = authResult
if (authResult.ok) {
result.actions.push({
action: authResult.existing ? 'oktopus_already_registered' : 'oktopus_pre_authorized',
deviceId: authResult.deviceId,
})
} else {
result.actions.push({ action: 'oktopus_auth_failed', error: authResult.error })
}
// Step 1b: Ensure device record exists in Oktopus MongoDB
if (result.endpointId) {
const mongoResult = await ensureDeviceRecord(result.endpointId, {
vendor: opts.vendor || 'TP-Link',
model: opts.model || '',
customer: customer || '',
alias: opts.alias || serial || '',
product_class: opts.equipment_type || 'ONT',
})
if (mongoResult.ok) {
result.actions.push({
action: mongoResult.created ? 'device_record_created' : 'device_record_exists',
endpointId: result.endpointId,
})
} else {
result.actions.push({ action: 'device_record_failed', error: mongoResult.error })
}
}
// Step 2: Store Oktopus credentials back in ERPNext (if new)
if (authResult.ok && authResult.password && equipment_name) {
try {
await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
method: 'PUT',
body: JSON.stringify({
oktopus_device_id: authResult.deviceId,
oktopus_password: authResult.password,
}),
})
result.actions.push({ action: 'erp_credentials_stored', equipment: equipment_name })
} catch (e) {
result.actions.push({ action: 'erp_update_failed', error: e.message })
}
}
// Step 3: Fetch fibre/OLT context from Service Location
if (service_location) {
try {
const locRes = await erpFetch(
`/api/resource/Service Location/${encodeURIComponent(service_location)}` +
'?fields=["name","address_line1","city","olt_ip","olt_port","olt_name","ont_id","ont_serial",' +
'"vlan_internet","vlan_manage","vlan_telephone","vlan_tv","connection_type","network_id"]'
)
const loc = locRes.data?.data || locRes.data || {}
result.fibre_context = {
location: loc.name,
address: `${loc.address_line1 || ''}, ${loc.city || ''}`.trim().replace(/^,\s*/, ''),
olt_ip: loc.olt_ip,
olt_port: loc.olt_port,
olt_name: loc.olt_name,
ont_id: loc.ont_id,
ont_serial: loc.ont_serial,
vlans: {
internet: loc.vlan_internet,
manage: loc.vlan_manage,
telephone: loc.vlan_telephone,
tv: loc.vlan_tv,
},
connection_type: loc.connection_type,
network_id: loc.network_id,
}
result.actions.push({ action: 'fibre_context_loaded', location: loc.name })
} catch (e) {
result.actions.push({ action: 'fibre_context_failed', error: e.message })
}
}
// Step 4: Generate TR-369 MQTT config summary for the CPE
if (result.deviceId) {
result.tr369_config = {
connection_type: 'MQTT',
mqtt_server: 'oss.gigafibre.ca',
mqtt_port: 1883,
agent_endpoint_id: result.endpointId,
agent_mqtt_topic: `oktopus/usp/v1/agent/${result.deviceId}`,
controller_endpoint_id: 'oktopusController',
controller_mqtt_topic: `oktopus/usp/v1/controller/${result.deviceId}`,
}
}
log(`[oktopus] Provision: ${serial} (${result.deviceId}) → ${result.actions.length} actions`)
return result
}
/* ── Bulk sync: ERPNext → Oktopus ─────────────────────────────────── */
/**
* Sync all active ONT equipment from ERPNext into Oktopus.
* Fetches Service Equipment where equipment_type=ONT and mac_address is set,
* then pre-authorizes each in Oktopus.
*
* @param {object} [opts]
* @param {string} [opts.status] - Filter by status (default: 'Actif')
* @param {number} [opts.limit] - Max devices to sync (default: all)
* @returns {{ total: number, authorized: number, existing: number, failed: number, errors: string[] }}
*/
async function bulkSync (opts = {}) {
const status = opts.status || 'Actif'
const limit = opts.limit || 0
const PAGE = 500
let offset = 0
let allEquip = []
log(`[oktopus] Bulk sync starting (status=${status}, limit=${limit || 'all'})`)
// Fetch all ONT equipment with MAC addresses from ERPNext
while (true) {
const filters = JSON.stringify([
['equipment_type', '=', 'ONT'],
['mac_address', 'is', 'set'],
['status', '=', status],
])
const fields = JSON.stringify(['name', 'serial_number', 'mac_address', 'service_location', 'customer'])
const path = `/api/resource/Service Equipment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${PAGE}&limit_start=${offset}&order_by=name asc`
const res = await erpFetch(path)
const rows = res.data?.data || []
allEquip.push(...rows)
if (rows.length < PAGE) break
offset += PAGE
if (limit && allEquip.length >= limit) break
}
if (limit) allEquip = allEquip.slice(0, limit)
log(`[oktopus] Found ${allEquip.length} ONT devices to sync`)
const result = { total: allEquip.length, authorized: 0, existing: 0, failed: 0, errors: [] }
for (const equip of allEquip) {
const authRes = await preAuthorizeDevice(equip.mac_address)
if (authRes.ok) {
// Ensure MongoDB record exists
const endpointId = generateEndpointId(equip.mac_address, equip.serial_number)
await ensureDeviceRecord(endpointId, {
vendor: equip.brand || '',
model: equip.model || '',
customer: equip.customer || '',
alias: equip.serial_number || '',
})
if (authRes.existing) {
result.existing++
} else {
result.authorized++
// Store credentials in ERPNext
try {
await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equip.name)}`, {
method: 'PUT',
body: JSON.stringify({
oktopus_device_id: authRes.deviceId,
oktopus_password: authRes.password,
}),
})
} catch (e) {
log(`[oktopus] Failed to store creds for ${equip.name}:`, e.message)
}
}
} else {
result.failed++
result.errors.push(`${equip.serial_number}: ${authRes.error}`)
}
// Small delay to avoid hammering Oktopus API
if (result.total > 50) await new Promise(r => setTimeout(r, 50))
}
log(`[oktopus] Bulk sync done: ${result.authorized} new, ${result.existing} existing, ${result.failed} failed`)
return result
}
/* ── HTTP handler for /oktopus/* routes ───────────────────────────── */
async function handle (req, res, method, path) {
const { json: sendJson, parseBody: parse } = require('./helpers')
try {
const action = path.replace('/oktopus/', '').split('/').filter(Boolean)[0]
// POST /oktopus/provision — provision single device
if (action === 'provision' && method === 'POST') {
const body = await parse(req)
if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' })
const result = await provisionDevice(body)
return sendJson(res, 200, result)
}
// POST /oktopus/pre-authorize — just create device auth
if (action === 'pre-authorize' && method === 'POST') {
const body = await parse(req)
if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' })
const result = await preAuthorizeDevice(body.mac)
return sendJson(res, 200, result)
}
// POST /oktopus/bulk-sync — sync all ONT devices from ERPNext
if (action === 'bulk-sync' && method === 'POST') {
const body = await parse(req)
const result = await bulkSync(body)
return sendJson(res, 200, result)
}
// GET /oktopus/status — check Oktopus connectivity
if (action === 'status' && method === 'GET') {
try {
const token = await authenticate()
return sendJson(res, 200, {
ok: !!token,
url: OKTOPUS_URL,
authenticated: !!token,
})
} catch (e) {
return sendJson(res, 200, { ok: false, url: OKTOPUS_URL, error: e.message })
}
}
// PUT /oktopus/walk — USP GetSupportedParams (like snmpwalk)
if (action === 'walk' && method === 'PUT') {
const body = await parse(req)
const sn = body.sn || body.serial
const rootPath = body.path || 'Device.'
if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' })
try {
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/parameters`, {
paths: [rootPath],
})
return sendJson(res, result.status, result.data)
} catch (e) {
return sendJson(res, 504, { error: e.message })
}
}
// PUT /oktopus/get — USP Get (read parameter values)
if (action === 'get' && method === 'PUT') {
const body = await parse(req)
const sn = body.sn || body.serial
const paths = body.paths || [body.path || 'Device.']
if (!sn) return sendJson(res, 400, { error: 'Missing sn' })
try {
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/get`, { paths })
return sendJson(res, result.status, result.data)
} catch (e) {
return sendJson(res, 504, { error: e.message })
}
}
// PUT /oktopus/set — USP Set (write parameter values)
if (action === 'set' && method === 'PUT') {
const body = await parse(req)
const sn = body.sn || body.serial
if (!sn || !body.params) return sendJson(res, 400, { error: 'Missing sn and params' })
try {
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/set`, body.params)
return sendJson(res, result.status, result.data)
} catch (e) {
return sendJson(res, 504, { error: e.message })
}
}
// GET /oktopus/mqtt-status — MQTT monitor status
if (action === 'mqtt-status' && method === 'GET') {
try {
const mqttMon = require('./oktopus-mqtt')
return sendJson(res, 200, mqttMon.getStatus())
} catch (e) {
return sendJson(res, 200, { ok: false, error: 'MQTT monitor not loaded' })
}
}
// POST /oktopus/mark-online — manually mark device online
if (action === 'mark-online' && method === 'POST') {
const body = await parse(req)
const sn = body.sn || body.endpointId
if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' })
try {
const mqttMon = require('./oktopus-mqtt')
const result = await mqttMon.markOnline(sn)
return sendJson(res, 200, result)
} catch (e) {
return sendJson(res, 500, { error: e.message })
}
}
// POST /oktopus/probe — probe device to check if online
if (action === 'probe' && method === 'POST') {
const body = await parse(req)
const sn = body.sn || body.endpointId
if (!sn) return sendJson(res, 400, { error: 'Missing sn' })
try {
const mqttMon = require('./oktopus-mqtt')
const result = await mqttMon.probeDevice(sn)
return sendJson(res, 200, result)
} catch (e) {
return sendJson(res, 504, { error: e.message })
}
}
// GET /oktopus/devices — list all devices from Oktopus
if (action === 'devices' && method === 'GET') {
try {
const result = await oktopusRequest('GET', '/api/device')
return sendJson(res, result.status, result.data)
} catch (e) {
return sendJson(res, 502, { error: e.message })
}
}
return sendJson(res, 404, { error: 'Unknown oktopus endpoint: ' + action })
} catch (e) {
log('[oktopus] Handler error:', e.message)
return require('./helpers').json(res, 500, { error: 'Oktopus error: ' + e.message })
}
}
module.exports = {
handle,
preAuthorizeDevice,
provisionDevice,
bulkSync,
normalizeMac,
generateEndpointId,
}