gigafibre-fsm/apps/ops/src/composables/usePhone.js
louispaulb 656f9d638c feat: telephony UI, performance indexes, Twilio softphone, lazy-load invoices
- Add PostgreSQL performance indexes migration script (1000x faster queries)
  Sales Invoice: 1,248ms → 28ms, Payment Entry: 443ms → 31ms
  Indexes on customer/party columns for all major tables
- Disable 3CX poller (PBX_ENABLED flag, using Twilio instead)
- Add TelephonyPage: full CRUD UI for Routr/Fonoster resources
  (trunks, agents, credentials, numbers, domains, peers)
- Add PhoneModal + usePhone composable (Twilio WebRTC softphone)
- Lazy-load invoices/payments (initial 5, expand on demand)
- Parallelize all API calls in ClientDetailPage (no waterfall)
- Add targo-hub service (SSE relay, SMS, voice, telephony API)
- Customer portal: invoice detail, ticket detail, messages pages
- Remove dead Ollama nginx upstream

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

425 lines
11 KiB
JavaScript

/**
* usePhone — WebRTC softphone via SIP.js + 3CX PBX.
*
* Registers as a SIP UA on the 3CX SBC (WebSocket),
* can make outbound calls and receive inbound calls.
*
* 3CX SIP credentials come from the xAPI: MyUser.AuthID / AuthPassword
* WSS endpoint is the 3CX SBC: wss://<sbc-fqdn>:443/ws or wss://<fqdn>:5001
*
* Usage:
* const phone = usePhone()
* await phone.register()
* await phone.call('+15145551234')
* phone.hangup()
*/
import { ref, computed, onUnmounted } from 'vue'
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
// ── 3CX Config (loaded from localStorage, set via Settings page) ──
const STORAGE_KEY = 'ops-phone-config'
const defaults = {
pbxUrl: 'https://targopbx.3cx.ca',
wssUrl: 'wss://targopbx.3cx.ca/wss', // SBC WebSocket — adjust once SBC is enabled
sipDomain: 'targopbx.3cx.ca',
// Per-user SIP creds (from 3CX MyUser API)
extension: '',
authId: '',
authPassword: '',
displayName: '',
}
export function getPhoneConfig () {
try {
return { ...defaults, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') }
} catch {
return { ...defaults }
}
}
export function savePhoneConfig (cfg) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
}
/**
* Authenticate to 3CX API and fetch SIP credentials for the user.
* Returns { extension, authId, authPassword, displayName } or throws.
*/
export async function fetch3cxCredentials (username, password) {
const cfg = getPhoneConfig()
// Step 1: Login to get Bearer token
const loginRes = await fetch(cfg.pbxUrl + '/webclient/api/Login/GetAccessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Username: username, Password: password }),
})
const loginData = await loginRes.json()
if (loginData.Status !== 'AuthSuccess') {
throw new Error('3CX login failed: ' + (loginData.Status || 'Unknown'))
}
const token = loginData.Token.access_token
// Step 2: Get SIP credentials from MyUser
const userRes = await fetch(cfg.pbxUrl + '/xapi/v1/MyUser', {
headers: { Authorization: 'Bearer ' + token },
})
if (!userRes.ok) throw new Error('Failed to fetch 3CX user: ' + userRes.status)
const user = await userRes.json()
return {
extension: user.Number,
authId: user.AuthID,
authPassword: user.AuthPassword,
displayName: (user.FirstName + ' ' + user.LastName).trim(),
token, // keep for API calls
}
}
// ── Singleton state (shared across components) ──
const registered = ref(false)
const registering = ref(false)
const callState = ref('idle') // idle | calling | ringing | active | ended
const callDuration = ref(0)
const callDirection = ref('') // out | in
const callRemote = ref('') // remote party number
const callRemoteName = ref('')
const callStartTime = ref(null)
const error = ref('')
const incomingCall = ref(null) // pending incoming session
let ua = null
let registerer = null
let currentSession = null
let durationTimer = null
let audioElement = null
function getAudioElement () {
if (!audioElement) {
audioElement = document.createElement('audio')
audioElement.autoplay = true
document.body.appendChild(audioElement)
}
return audioElement
}
function startDurationTimer () {
callStartTime.value = Date.now()
callDuration.value = 0
durationTimer = setInterval(() => {
if (callStartTime.value) {
callDuration.value = Math.floor((Date.now() - callStartTime.value) / 1000)
}
}, 1000)
}
function stopDurationTimer () {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
}
function resetCallState () {
callState.value = 'idle'
callDuration.value = 0
callDirection.value = ''
callRemote.value = ''
callRemoteName.value = ''
callStartTime.value = null
incomingCall.value = null
currentSession = null
stopDurationTimer()
}
function attachSessionListeners (session) {
currentSession = session
session.stateChange.addListener((state) => {
switch (state) {
case SessionState.Establishing:
callState.value = 'ringing'
break
case SessionState.Established:
callState.value = 'active'
startDurationTimer()
// Attach remote audio
setupRemoteMedia(session)
break
case SessionState.Terminated:
callState.value = 'ended'
stopDurationTimer()
cleanupMedia()
// Auto-reset after 2s
setTimeout(resetCallState, 2000)
break
}
})
}
function setupRemoteMedia (session) {
const audio = getAudioElement()
const pc = session.sessionDescriptionHandler?.peerConnection
if (pc) {
pc.getReceivers().forEach((receiver) => {
if (receiver.track && receiver.track.kind === 'audio') {
const stream = new MediaStream([receiver.track])
audio.srcObject = stream
}
})
}
}
function cleanupMedia () {
if (audioElement) {
audioElement.srcObject = null
}
}
export function usePhone () {
/**
* Register with 3CX SBC via WebSocket SIP.
*/
async function register () {
if (registered.value || registering.value) return
registering.value = true
error.value = ''
const cfg = getPhoneConfig()
if (!cfg.extension || !cfg.authId || !cfg.authPassword) {
error.value = 'Phone not configured. Go to Settings → Phone.'
registering.value = false
return
}
try {
const uri = UserAgent.makeURI(`sip:${cfg.extension}@${cfg.sipDomain}`)
if (!uri) throw new Error('Invalid SIP URI')
ua = new UserAgent({
uri,
transportOptions: {
server: cfg.wssUrl,
},
authorizationUsername: cfg.authId,
authorizationPassword: cfg.authPassword,
displayName: cfg.displayName || cfg.extension,
// Log level: warn in prod
logLevel: 'warn',
// Handle incoming calls
delegate: {
onInvite: (invitation) => {
callDirection.value = 'in'
callRemote.value = invitation.remoteIdentity?.uri?.user || 'Unknown'
callRemoteName.value = invitation.remoteIdentity?.displayName || ''
callState.value = 'ringing'
incomingCall.value = invitation
attachSessionListeners(invitation)
},
},
})
await ua.start()
registerer = new Registerer(ua)
await registerer.register()
registered.value = true
} catch (e) {
error.value = 'Registration failed: ' + e.message
console.error('[usePhone] register error:', e)
} finally {
registering.value = false
}
}
/**
* Unregister and disconnect.
*/
async function unregister () {
try {
if (currentSession) {
try { currentSession.bye?.() || currentSession.cancel?.() } catch {}
}
if (registerer) await registerer.unregister().catch(() => {})
if (ua) await ua.stop().catch(() => {})
} catch {}
registered.value = false
registering.value = false
resetCallState()
ua = null
registerer = null
}
/**
* Make outbound call.
*/
async function call (number) {
if (!ua || !registered.value) {
error.value = 'Phone not registered'
return
}
if (callState.value !== 'idle') {
error.value = 'Already in a call'
return
}
error.value = ''
const cfg = getPhoneConfig()
const target = UserAgent.makeURI(`sip:${number}@${cfg.sipDomain}`)
if (!target) {
error.value = 'Invalid number'
return
}
callDirection.value = 'out'
callRemote.value = number
callState.value = 'calling'
const inviter = new Inviter(ua, target, {
sessionDescriptionHandlerOptions: {
constraints: { audio: true, video: false },
},
})
attachSessionListeners(inviter)
try {
await inviter.invite()
} catch (e) {
error.value = 'Call failed: ' + e.message
resetCallState()
}
}
/**
* Answer incoming call.
*/
async function answer () {
if (!incomingCall.value) return
try {
await incomingCall.value.accept({
sessionDescriptionHandlerOptions: {
constraints: { audio: true, video: false },
},
})
} catch (e) {
error.value = 'Answer failed: ' + e.message
}
}
/**
* Reject incoming call.
*/
function reject () {
if (incomingCall.value) {
try { incomingCall.value.reject() } catch {}
resetCallState()
}
}
/**
* Hang up active call.
*/
function hangup () {
if (!currentSession) return
try {
if (callState.value === 'active') {
currentSession.bye()
} else {
// Ringing or calling — cancel
currentSession.cancel?.() || currentSession.reject?.()
}
} catch {
resetCallState()
}
}
/**
* Toggle mute.
*/
const muted = ref(false)
function toggleMute () {
if (!currentSession) return
const pc = currentSession.sessionDescriptionHandler?.peerConnection
if (pc) {
pc.getSenders().forEach((sender) => {
if (sender.track && sender.track.kind === 'audio') {
sender.track.enabled = !sender.track.enabled
muted.value = !sender.track.enabled
}
})
}
}
/**
* Toggle hold (re-INVITE with sendonly/recvonly).
*/
const held = ref(false)
async function toggleHold () {
// Simplified hold — mute + hold state
// Full SIP hold requires re-INVITE, complex with SIP.js
held.value = !held.value
toggleMute() // mute audio as basic hold
}
/**
* Send DTMF tone.
*/
function sendDtmf (tone) {
if (!currentSession) return
try {
currentSession.sessionDescriptionHandler?.sendDtmf(tone)
} catch {
// Fallback: INFO method
try {
currentSession.info({
requestOptions: {
body: {
contentDisposition: 'render',
contentType: 'application/dtmf-relay',
content: `Signal=${tone}\r\nDuration=160`,
},
},
})
} catch {}
}
}
const formattedDuration = computed(() => {
const m = Math.floor(callDuration.value / 60)
const s = callDuration.value % 60
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
})
onUnmounted(() => {
// Don't unregister on unmount — keep registration alive across page navigations
// Only cleanup when explicitly called
})
return {
// State
registered,
registering,
callState,
callDuration,
formattedDuration,
callDirection,
callRemote,
callRemoteName,
incomingCall,
error,
muted,
held,
// Actions
register,
unregister,
call,
answer,
reject,
hangup,
toggleMute,
toggleHold,
sendDtmf,
}
}