- 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>
425 lines
11 KiB
JavaScript
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,
|
|
}
|
|
}
|