- 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>
163 lines
7.6 KiB
JavaScript
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 }
|