gigafibre-fsm/services/targo-hub/lib/pbx.js
louispaulb 320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

163 lines
7.6 KiB
JavaScript

'use strict'
const cfg = require('./config')
const { log, json, parseBody, httpRequest, lookupCustomerByPhone, createCommunication } = require('./helpers')
const { broadcast, broadcastAll } = require('./sse')
let pbxToken = null, pbxTokenExpiry = 0
const processedCallIds = new Set()
async function get3cxToken () {
if (pbxToken && Date.now() < pbxTokenExpiry - 60000) return pbxToken
if (!cfg.PBX_USER || !cfg.PBX_PASS) return null
try {
const res = await httpRequest(cfg.PBX_URL, '/webclient/api/Login/GetAccessToken', {
method: 'POST', body: { Username: cfg.PBX_USER, Password: cfg.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
}
function formatDuration (sec) {
return `${Math.floor(sec / 60)}m${(sec % 60).toString().padStart(2, '0')}s`
}
function parseIsoDuration (td) {
if (typeof td === 'number') return td
if (typeof td !== 'string' || !td.startsWith('PT')) return 0
const hr = td.match(/(\d+)H/), mn = td.match(/(\d+)M/), sc = td.match(/(\d+)S/)
return (hr ? parseInt(hr[1]) * 3600 : 0) + (mn ? parseInt(mn[1]) * 60 : 0) + (sc ? parseInt(sc[1]) : 0)
}
async function logCallToErp (remotePhone, isOutbound, duration, customerLabel, customerName, displayName) {
if (!customerName || duration <= 0) return
await createCommunication({
communication_type: 'Communication', communication_medium: 'Phone',
sent_or_received: isOutbound ? 'Sent' : 'Received',
sender: 'sms@gigafibre.ca',
sender_full_name: isOutbound ? (displayName || 'Targo Ops') : customerLabel,
phone_no: remotePhone,
content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${formatDuration(duration)}`,
subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
reference_doctype: 'Customer', reference_name: customerName, status: 'Linked',
})
}
async function callEvent (req, res) {
const body = await parseBody(req)
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()
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
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})`)
json(res, 200, { ok: true })
setImmediate(async () => {
try {
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?.name, customerLabel = customer?.customer_name || 'Inconnu'
if (isCallEnd) await logCallToErp(remotePhone, isOutbound, duration, customerLabel, customerName)
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(),
}
if (customerName) broadcast('customer:' + customerName, 'call-event', eventData)
broadcastAll('call-event', eventData)
log(`3CX logged: ${remotePhone}${customerName || 'UNKNOWN'} (${callId})`)
} catch (e) { log('3CX call processing error:', e.message) }
})
}
async function poll3cxCallLog () {
const token = await get3cxToken()
if (!token) return
try {
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
const url = `${cfg.PBX_URL}/xapi/v1/ReportCallLogData?$top=20&$orderby=StartTime%20desc&$filter=StartTime%20gt%20${encodeURIComponent(since)}`
const res = await httpRequest(cfg.PBX_URL, url.replace(cfg.PBX_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
if (!call.TalkingDuration && !call.Answered) continue
processedCallIds.add(callId)
const direction = (call.Direction || '').toLowerCase().includes('inbound') ? 'in' : 'out'
const isOutbound = direction === 'out'
const remotePhone = isOutbound ? (call.DestinationCallerId || call.DestinationDn || '') : (call.SourceCallerId || call.SourceDn || '')
const ext = isOutbound ? call.SourceDn : call.DestinationDn
const durationSec = parseIsoDuration(call.TalkingDuration || '')
if (durationSec < 3 && !call.Answered) continue
log(`3CX POLL: ${call.SourceCallerId || call.SourceDn}${call.DestinationCallerId || call.DestinationDn} dir=${direction} dur=${durationSec}s (${callId})`)
try {
const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null
const customerName = customer?.name, customerLabel = customer?.customer_name || 'Inconnu'
if (customerName) {
const suffix = call.Answered ? '' : ' (manque)'
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: ${formatDuration(durationSec)}${suffix}`,
subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
reference_doctype: 'Customer', reference_name: customerName, status: 'Linked',
})
log(`3CX logged to ERPNext: ${remotePhone}${customerName} (${formatDuration(durationSec)})`)
}
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 IDs (keep last 500)
if (processedCallIds.size > 500) {
const arr = [...processedCallIds].slice(-500)
processedCallIds.clear()
arr.forEach(id => processedCallIds.add(id))
}
} catch (e) { log('3CX poll error:', e.message) }
}
function startPoller () {
if (!cfg.PBX_ENABLED) { log('3CX poller: DISABLED (PBX_ENABLED != 1)'); return }
if (!cfg.PBX_USER || !cfg.PBX_PASS) { log('3CX poller: DISABLED (no credentials)'); return }
log(`3CX poller: ENABLED (every ${cfg.PBX_POLL_INTERVAL / 1000}s) → ${cfg.PBX_URL}`)
setTimeout(poll3cxCallLog, 5000)
setInterval(poll3cxCallLog, cfg.PBX_POLL_INTERVAL)
}
module.exports = { callEvent, startPoller }