diff --git a/apps/client/src/api/portal.js b/apps/client/src/api/portal.js
index 36b09cf..c3ebcf0 100644
--- a/apps/client/src/api/portal.js
+++ b/apps/client/src/api/portal.js
@@ -116,3 +116,143 @@ export async function fetchAddresses (customer) {
const data = await apiGet(path)
return data.data || []
}
+
+/**
+ * Fetch a single Sales Invoice with line items.
+ */
+export async function fetchInvoice (invoiceName) {
+ const data = await apiGet(`/api/resource/Sales Invoice/${encodeURIComponent(invoiceName)}`)
+ return data.data
+}
+
+/**
+ * Fetch invoice HTML print preview (Jinja rendered).
+ */
+export async function fetchInvoiceHTML (invoiceName, format = 'Facture TARGO') {
+ const url = `${BASE_URL}/api/method/frappe.www.printview.get_html_and_style?doc=Sales Invoice&name=${encodeURIComponent(invoiceName)}&print_format=${encodeURIComponent(format)}&no_letterhead=0`
+ const res = await authFetch(url)
+ if (!res.ok) throw new Error('Print preview failed')
+ const data = await res.json()
+ return data.message || {}
+}
+
+/**
+ * Fetch a single Issue with full details.
+ */
+export async function fetchTicket (ticketName) {
+ const data = await apiGet(`/api/resource/Issue/${encodeURIComponent(ticketName)}`)
+ return data.data
+}
+
+/**
+ * Fetch Communications linked to a reference (Issue or Customer).
+ */
+export async function fetchCommunications (refDoctype, refName, limit = 100) {
+ const filters = JSON.stringify([
+ ['reference_doctype', '=', refDoctype],
+ ['reference_name', '=', refName],
+ ])
+ const fields = JSON.stringify([
+ 'name', 'communication_medium', 'communication_type',
+ 'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
+ 'subject', 'content', 'creation', 'status',
+ 'reference_doctype', 'reference_name',
+ ])
+ const path = `/api/resource/Communication?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=${limit}`
+ const data = await apiGet(path)
+ return data.data || []
+}
+
+/**
+ * Fetch all Communications for a customer (across all references + direct).
+ * Returns Communications where reference is Customer or Issue belonging to customer.
+ */
+export async function fetchAllCommunications (customer, limit = 200) {
+ // Direct communications on customer
+ const directFilters = JSON.stringify([
+ ['reference_doctype', '=', 'Customer'],
+ ['reference_name', '=', customer],
+ ])
+ const fields = JSON.stringify([
+ 'name', 'communication_medium', 'communication_type',
+ 'sent_or_received', 'sender', 'sender_full_name', 'phone_no',
+ 'subject', 'content', 'creation', 'status',
+ 'reference_doctype', 'reference_name',
+ ])
+ const directPath = `/api/resource/Communication?filters=${encodeURIComponent(directFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
+ const directData = await apiGet(directPath)
+
+ // Communications on customer's Issues
+ const issueFilters = JSON.stringify([
+ ['reference_doctype', '=', 'Issue'],
+ ['reference_name', 'in', []], // will be filled after ticket fetch
+ ])
+ // First get the customer's ticket names
+ const ticketFilters = JSON.stringify([['customer', '=', customer]])
+ const ticketFields = JSON.stringify(['name'])
+ const ticketPath = `/api/resource/Issue?filters=${encodeURIComponent(ticketFilters)}&fields=${encodeURIComponent(ticketFields)}&limit_page_length=200`
+ const ticketData = await apiGet(ticketPath)
+ const ticketNames = (ticketData.data || []).map(t => t.name)
+
+ let issueComs = []
+ if (ticketNames.length) {
+ const issueComsFilters = JSON.stringify([
+ ['reference_doctype', '=', 'Issue'],
+ ['reference_name', 'in', ticketNames],
+ ])
+ const issueComsPath = `/api/resource/Communication?filters=${encodeURIComponent(issueComsFilters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit_page_length=${limit}`
+ const issueComsData = await apiGet(issueComsPath)
+ issueComs = issueComsData.data || []
+ }
+
+ // Merge, deduplicate, sort desc
+ const all = [...(directData.data || []), ...issueComs]
+ const seen = new Set()
+ const unique = all.filter(c => {
+ if (seen.has(c.name)) return false
+ seen.add(c.name)
+ return true
+ })
+ unique.sort((a, b) => new Date(b.creation) - new Date(a.creation))
+ return unique
+}
+
+/**
+ * Post a reply on a ticket (creates a Communication).
+ */
+export async function replyToTicket (ticketName, message) {
+ const res = await authFetch(BASE_URL + '/api/resource/Communication', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ communication_type: 'Communication',
+ communication_medium: 'Other',
+ sent_or_received: 'Sent',
+ sender: 'portal@gigafibre.ca',
+ content: message,
+ reference_doctype: 'Issue',
+ reference_name: ticketName,
+ status: 'Linked',
+ }),
+ })
+ if (!res.ok) throw new Error('Failed to send reply')
+ const data = await res.json()
+ return data.data
+}
+
+/**
+ * Fetch Comments linked to a document (visible to portal users).
+ */
+export async function fetchComments (refDoctype, refName) {
+ const filters = JSON.stringify([
+ ['reference_doctype', '=', refDoctype],
+ ['reference_name', '=', refName],
+ ['comment_type', '=', 'Comment'],
+ ])
+ const fields = JSON.stringify([
+ 'name', 'comment_by', 'content', 'creation',
+ ])
+ const path = `/api/resource/Comment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=100`
+ const data = await apiGet(path)
+ return data.data || []
+}
diff --git a/apps/client/src/composables/usePolling.js b/apps/client/src/composables/usePolling.js
new file mode 100644
index 0000000..40ff31b
--- /dev/null
+++ b/apps/client/src/composables/usePolling.js
@@ -0,0 +1,62 @@
+import { ref, onUnmounted } from 'vue'
+
+/**
+ * Smart incremental polling composable.
+ * Calls `fetchFn()` at `interval` ms. Compares result count/IDs
+ * with previous to detect new items and trigger `onNew` callback.
+ *
+ * @param {Function} fetchFn - async function that returns array of items
+ * @param {Object} opts
+ * @param {number} opts.interval - poll interval in ms (default 10000)
+ * @param {Function} opts.getId - function to get unique ID from item (default: item => item.name)
+ * @param {Function} opts.onNew - callback when new items detected, receives array of new items
+ */
+export function usePolling (fetchFn, opts = {}) {
+ const interval = opts.interval || 10000
+ const getId = opts.getId || (item => item.name)
+ const onNew = opts.onNew || (() => {})
+
+ const knownIds = ref(new Set())
+ let timer = null
+ let running = false
+
+ function seedKnownIds (items) {
+ knownIds.value = new Set(items.map(getId))
+ }
+
+ async function poll () {
+ if (running) return
+ running = true
+ try {
+ const items = await fetchFn()
+ const newItems = items.filter(item => !knownIds.value.has(getId(item)))
+ if (newItems.length) {
+ for (const item of items) {
+ knownIds.value.add(getId(item))
+ }
+ onNew(newItems, items)
+ }
+ } catch {
+ // Silent fail on poll
+ } finally {
+ running = false
+ }
+ }
+
+ function start () {
+ stop()
+ timer = setInterval(poll, interval)
+ }
+
+ function stop () {
+ if (timer) {
+ clearInterval(timer)
+ timer = null
+ }
+ }
+
+ // Auto-cleanup on component unmount
+ onUnmounted(stop)
+
+ return { start, stop, poll, seedKnownIds }
+}
diff --git a/apps/client/src/composables/useSSE.js b/apps/client/src/composables/useSSE.js
new file mode 100644
index 0000000..317d8af
--- /dev/null
+++ b/apps/client/src/composables/useSSE.js
@@ -0,0 +1,65 @@
+import { ref, onUnmounted } from 'vue'
+
+const HUB_URL = 'https://msg.gigafibre.ca'
+
+/**
+ * SSE composable for real-time Communication events from targo-hub.
+ */
+export function useSSE (opts = {}) {
+ const connected = ref(false)
+ let es = null
+ let reconnectTimer = null
+ let reconnectDelay = 1000
+
+ function connect (topics) {
+ disconnect()
+ if (!topics || !topics.length) return
+
+ const url = `${HUB_URL}/sse?topics=${encodeURIComponent(topics.join(','))}`
+ es = new EventSource(url)
+
+ es.onopen = () => {
+ connected.value = true
+ reconnectDelay = 1000
+ }
+
+ es.addEventListener('message', (e) => {
+ try {
+ const data = JSON.parse(e.data)
+ if (opts.onMessage) opts.onMessage(data)
+ } catch {}
+ })
+
+ es.addEventListener('sms-incoming', (e) => {
+ try {
+ const data = JSON.parse(e.data)
+ if (opts.onSmsIncoming) opts.onSmsIncoming(data)
+ } catch {}
+ })
+
+ es.onerror = () => {
+ connected.value = false
+ if (es && es.readyState === EventSource.CLOSED) {
+ scheduleReconnect(topics)
+ }
+ }
+ }
+
+ function scheduleReconnect (topics) {
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ reconnectTimer = setTimeout(() => {
+ reconnectDelay = Math.min(reconnectDelay * 2, 30000)
+ connect(topics)
+ }, reconnectDelay)
+ }
+
+ function disconnect () {
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
+ if (es) { es.close(); es = null }
+ connected.value = false
+ }
+
+ onUnmounted(disconnect)
+
+ return { connect, disconnect, connected }
+}
diff --git a/apps/client/src/css/app.scss b/apps/client/src/css/app.scss
index feb8550..d48c83d 100644
--- a/apps/client/src/css/app.scss
+++ b/apps/client/src/css/app.scss
@@ -63,3 +63,14 @@ body {
color: var(--gf-text);
margin-bottom: 16px;
}
+
+// Clickable table rows
+.clickable-table {
+ .q-table tbody tr {
+ cursor: pointer;
+ transition: background 0.15s;
+ &:hover {
+ background: rgba(14, 165, 233, 0.05);
+ }
+ }
+}
diff --git a/apps/client/src/layouts/PortalLayout.vue b/apps/client/src/layouts/PortalLayout.vue
index 0d74e78..163b3ac 100644
--- a/apps/client/src/layouts/PortalLayout.vue
+++ b/apps/client/src/layouts/PortalLayout.vue
@@ -63,6 +63,7 @@ const navLinks = [
{ to: '/', icon: 'dashboard', label: 'Tableau de bord' },
{ to: '/invoices', icon: 'receipt_long', label: 'Factures' },
{ to: '/tickets', icon: 'support_agent', label: 'Support' },
+ { to: '/messages', icon: 'chat', label: 'Messages' },
{ to: '/me', icon: 'person', label: 'Mon compte' },
]
diff --git a/apps/client/src/pages/DashboardPage.vue b/apps/client/src/pages/DashboardPage.vue
index cb1c9d9..a00b932 100644
--- a/apps/client/src/pages/DashboardPage.vue
+++ b/apps/client/src/pages/DashboardPage.vue
@@ -38,7 +38,7 @@
Dernières factures
-
+
{{ inv.name }}
{{ formatDate(inv.posting_date) }}
diff --git a/apps/client/src/pages/InvoiceDetailPage.vue b/apps/client/src/pages/InvoiceDetailPage.vue
new file mode 100644
index 0000000..17f0fee
--- /dev/null
+++ b/apps/client/src/pages/InvoiceDetailPage.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
{{ invoiceName }}
+
+
+
+
+
+
+
+
+
Total
+
{{ formatMoney(invoice.grand_total) }}
+
+
+
+
+
Solde
+
+ {{ formatMoney(invoice.outstanding_amount) }}
+
+
+
+
+
+
Date
+
{{ formatDate(invoice.posting_date) }}
+
+
+
+
+
+
+
+
Détails
+
+
+ {{ formatMoney(props.value) }}
+
+
+ {{ formatMoney(props.value) }}
+
+
+
+
+
+
+
+ Taxes:
+ {{ formatMoney(invoice.total_taxes_and_charges) }}
+
+
+ Grand total:
+ {{ formatMoney(invoice.grand_total) }}
+
+
+
+
+
+
+
+
Aperçu de la facture
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/pages/InvoicesPage.vue b/apps/client/src/pages/InvoicesPage.vue
index 8d11791..8b470eb 100644
--- a/apps/client/src/pages/InvoicesPage.vue
+++ b/apps/client/src/pages/InvoicesPage.vue
@@ -9,8 +9,9 @@
:loading="loading"
:pagination="pagination"
@request="onRequest"
+ @row-click="(evt, row) => router.push('/invoices/' + row.name)"
flat bordered
- class="bg-white"
+ class="bg-white clickable-table"
no-data-label="Aucune facture"
>
@@ -47,10 +48,12 @@
+
+
diff --git a/apps/client/src/pages/TicketDetailPage.vue b/apps/client/src/pages/TicketDetailPage.vue
new file mode 100644
index 0000000..e34fe9c
--- /dev/null
+++ b/apps/client/src/pages/TicketDetailPage.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
{{ ticket?.subject || ticketName }}
+
+ {{ ticketName }} · Créé le {{ formatDate(ticket.creation) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Conversation
+
+
+
+ Aucun message pour le moment
+
+
+
+
+
+
+ {{ group.date }}
+
+
+
+
+
+
+
+
+ {{ entry.sender_full_name || entry.sender || 'Système' }}
+
+
+ {{ formatTime(entry.creation) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Détails
+
+
+
+ Statut
+
+
+
+
+
+
+
+ Priorité
+
+
+
+
+
+
+
+ Type
+ {{ ticket.issue_type }}
+
+
+
+
+ Créé le
+ {{ formatDate(ticket.creation) }}
+
+
+
+
+ Résolu le
+ {{ formatDate(ticket.resolution_date) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/client/src/pages/TicketsPage.vue b/apps/client/src/pages/TicketsPage.vue
index 1684bbe..55f21d1 100644
--- a/apps/client/src/pages/TicketsPage.vue
+++ b/apps/client/src/pages/TicketsPage.vue
@@ -10,8 +10,9 @@
:columns="columns"
row-key="name"
:loading="loading"
+ @row-click="(evt, row) => router.push('/tickets/' + row.name)"
flat bordered
- class="bg-white"
+ class="bg-white clickable-table"
no-data-label="Aucun ticket"
:pagination="{ rowsPerPage: 50 }"
>
@@ -58,11 +59,13 @@
+
+
diff --git a/apps/ops/src/components/customer/SmsThread.vue b/apps/ops/src/components/customer/SmsThread.vue
index b2539e3..b11bc25 100644
--- a/apps/ops/src/components/customer/SmsThread.vue
+++ b/apps/ops/src/components/customer/SmsThread.vue
@@ -131,8 +131,10 @@ async function sendReply () {
await sendTestSms(phone, text, props.customerName)
reply.value = ''
Notify.create({ type: 'positive', message: 'SMS envoye', timeout: 2000 })
- // Reload after a short delay to let n8n log the Communication
- setTimeout(() => loadMessages(), 1500)
+ // Reload immediately — hub logs Communication before returning
+ await loadMessages()
+ // Safety: reload again in case of propagation delay
+ setTimeout(() => loadMessages(), 2000)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
} finally {
diff --git a/apps/ops/src/composables/usePhone.js b/apps/ops/src/composables/usePhone.js
new file mode 100644
index 0000000..3711e03
--- /dev/null
+++ b/apps/ops/src/composables/usePhone.js
@@ -0,0 +1,424 @@
+/**
+ * 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://:443/ws or wss://: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,
+ }
+}
diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js
index 5272a92..5fcf9e9 100644
--- a/apps/ops/src/config/nav.js
+++ b/apps/ops/src/config/nav.js
@@ -6,7 +6,8 @@ export const navItems = [
{ path: '/tickets', icon: 'Ticket', label: 'Tickets' },
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports' },
- { path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
+ { path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
+ { path: '/telephony', icon: 'Phone', label: 'Téléphonie' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres' },
]
diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue
index 566565e..0cb0c82 100644
--- a/apps/ops/src/layouts/MainLayout.vue
+++ b/apps/ops/src/layouts/MainLayout.vue
@@ -107,10 +107,10 @@ import { listDocs } from 'src/api/erp'
import { navItems } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
- ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
+ ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
} from 'lucide-vue-next'
-const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
+const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Phone, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
const auth = useAuthStore()
const route = useRoute()
diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue
index e675005..5741243 100644
--- a/apps/ops/src/pages/ClientDetailPage.vue
+++ b/apps/ops/src/pages/ClientDetailPage.vue
@@ -335,7 +335,7 @@
- Factures ({{ invoices.length }})
+ Factures ({{ invoices.length }}{{ !invoicesExpanded ? '+' : '' }})
Solde: {{ formatMoney(totalOutstanding) }}
@@ -375,6 +375,10 @@
+
+
+
@@ -383,7 +387,7 @@
- Paiements ({{ payments.length }})
+ Paiements ({{ payments.length }}{{ !paymentsExpanded ? '+' : '' }})
@@ -400,6 +404,10 @@
{{ formatMoney(props.row.paid_amount) }}
+
+
+
@@ -583,16 +591,15 @@ async function loadCustomer (id) {
comments.value = []
contact.value = null
modalOpen.value = false
+ invoicesExpanded.value = false
+ paymentsExpanded.value = false
try {
- const cust = await getDoc('Customer', id)
- for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
- customer.value = cust
-
const custFilter = { customer: id }
- const partyFilter = { party_type: 'Customer', party: id }
- const [locs, subs, equip, tix, invs, pays, ctc, memos] = await Promise.all([
+ // All queries in ONE parallel batch (including customer doc) — no waterfall
+ const [cust, locs, subs, equip, tix, invs, pays, memos, balRes] = await Promise.all([
+ getDoc('Customer', id),
listDocs('Service Location', {
filters: custFilter,
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
@@ -607,7 +614,6 @@ async function loadCustomer (id) {
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
limit: 200, orderBy: 'start_date desc',
}).then(subs => subs.map(s => ({
- // Normalize to match template field names (formerly from Subscription doctype)
...s,
actual_price: s.monthly_price,
custom_description: s.plan_name,
@@ -617,7 +623,6 @@ async function loadCustomer (id) {
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
cancel_at_period_end: 0,
cancelation_date: s.cancellation_date,
- // Map status: Actif→Active, Annulé→Cancelled, etc.
status: s.status === 'Actif' ? 'Active' : s.status === 'Annulé' ? 'Cancelled' : s.status === 'Suspendu' ? 'Cancelled' : s.status === 'En attente' ? 'Active' : s.status,
}))),
listDocs('Service Equipment', {
@@ -635,21 +640,23 @@ async function loadCustomer (id) {
listDocs('Sales Invoice', {
filters: custFilter,
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
- limit: 50, orderBy: 'posting_date desc, name desc',
+ limit: 5, orderBy: 'posting_date desc, name desc',
}),
listDocs('Payment Entry', {
filters: { party_type: 'Customer', party: id },
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
- limit: 50, orderBy: 'posting_date desc',
+ limit: 5, orderBy: 'posting_date desc',
}),
- listDocs('Contact', { filters: {}, fields: ['name', 'first_name', 'last_name', 'email_id', 'mobile_no', 'phone'], limit: 1 }).catch(() => []),
listDocs('Comment', {
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'],
limit: 50, orderBy: 'creation desc',
}).catch(() => []),
+ authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null),
])
+ for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
+ customer.value = cust
locations.value = locs
subscriptions.value = subs
invalidateAll()
@@ -657,13 +664,12 @@ async function loadCustomer (id) {
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs
payments.value = pays
- contact.value = ctc.length ? ctc[0] : null
+ contact.value = null
comments.value = memos
- try {
- const balRes = await authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id))
- if (balRes.ok) { accountBalance.value = (await balRes.json()).message }
- } catch {}
+ if (balRes && balRes.ok) {
+ try { accountBalance.value = (await balRes.json()).message } catch {}
+ }
} catch {
customer.value = null
} finally {
@@ -671,6 +677,40 @@ async function loadCustomer (id) {
}
}
+// ── Lazy-load more invoices/payments on demand ──
+const invoicesExpanded = ref(false)
+const paymentsExpanded = ref(false)
+const loadingMoreInvoices = ref(false)
+const loadingMorePayments = ref(false)
+
+async function loadAllInvoices () {
+ if (invoicesExpanded.value || !customer.value) return
+ loadingMoreInvoices.value = true
+ try {
+ invoices.value = await listDocs('Sales Invoice', {
+ filters: { customer: customer.value.name },
+ fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
+ limit: 200, orderBy: 'posting_date desc, name desc',
+ })
+ invoicesExpanded.value = true
+ } catch {}
+ loadingMoreInvoices.value = false
+}
+
+async function loadAllPayments () {
+ if (paymentsExpanded.value || !customer.value) return
+ loadingMorePayments.value = true
+ try {
+ payments.value = await listDocs('Payment Entry', {
+ filters: { party_type: 'Customer', party: customer.value.name },
+ fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
+ limit: 200, orderBy: 'posting_date desc',
+ })
+ paymentsExpanded.value = true
+ } catch {}
+ loadingMorePayments.value = false
+}
+
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
onMounted(() => loadCustomer(props.id))
diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue
index 4c2b5c7..b3c2d3e 100644
--- a/apps/ops/src/pages/SettingsPage.vue
+++ b/apps/ops/src/pages/SettingsPage.vue
@@ -138,6 +138,49 @@
+
+
+
+ 3CX — Telephone WebRTC
+
+
+
+
+
+
+
+
+
+
+ Connectez-vous avec vos identifiants 3CX pour recuperer automatiquement vos credentials SIP.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SIP credentials OK — Extension {{ phoneConfig.extension }}
+
+
+
+ Le SBC doit etre active dans 3CX Admin → Settings pour les appels WebRTC.
+
3CX Admin →
+
+
+
@@ -166,6 +209,7 @@ import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { sendTestSms } from 'src/api/sms'
+import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
const loading = ref(true)
const settings = ref({})
@@ -173,6 +217,36 @@ const showToken = ref(false)
const testingSms = ref(false)
const smsTestResult = ref(null)
+// 3CX Phone config
+const phoneConfig = ref(getPhoneConfig())
+const pbxUsername = ref('')
+const pbxPassword = ref('')
+const loggingIn3cx = ref(false)
+
+function savePhone () {
+ savePhoneConfig(phoneConfig.value)
+ Notify.create({ type: 'positive', message: 'Config telephone sauvegardee', timeout: 1500 })
+}
+
+async function login3cx () {
+ if (!pbxUsername.value || !pbxPassword.value) return
+ loggingIn3cx.value = true
+ try {
+ const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
+ phoneConfig.value.extension = creds.extension
+ phoneConfig.value.authId = creds.authId
+ phoneConfig.value.authPassword = creds.authPassword
+ phoneConfig.value.displayName = creds.displayName
+ savePhoneConfig(phoneConfig.value)
+ pbxPassword.value = ''
+ Notify.create({ type: 'positive', message: `Connecte — Extension ${creds.extension} (${creds.displayName})`, timeout: 3000 })
+ } catch (e) {
+ Notify.create({ type: 'negative', message: '3CX login echoue: ' + e.message, timeout: 4000 })
+ } finally {
+ loggingIn3cx.value = false
+ }
+}
+
// Snapshot for change detection
const snapshots = {}
diff --git a/apps/ops/src/pages/TelephonyPage.vue b/apps/ops/src/pages/TelephonyPage.vue
new file mode 100644
index 0000000..a2ee503
--- /dev/null
+++ b/apps/ops/src/pages/TelephonyPage.vue
@@ -0,0 +1,359 @@
+
+
+
+
+
+
+
+ {{ overview[kpi.key] ?? '—' }}
+ {{ kpi.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rafraîchir
+
+
+
+
+
+
+
+
+
+ Modifier
+
+
+ Supprimer
+
+
+
+
+
+
+
+
+
+
+ Aucun élément. Cliquez sur « Ajouter » pour créer.
+
+
+
+
+
+
+
+
+
+
+ {{ editingRow ? 'Modifier' : 'Créer' }} {{ activeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Confirmer la suppression
+ Supprimer {{ deletingRow?.name || deletingRow?.ref }} ?
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js
index 0a7ea34..aad356b 100644
--- a/apps/ops/src/router/index.js
+++ b/apps/ops/src/router/index.js
@@ -14,6 +14,7 @@ const routes = [
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
+ { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
],
},
diff --git a/scripts/migration/add_performance_indexes.sql b/scripts/migration/add_performance_indexes.sql
new file mode 100644
index 0000000..1d0cd9a
--- /dev/null
+++ b/scripts/migration/add_performance_indexes.sql
@@ -0,0 +1,63 @@
+-- ============================================================
+-- Performance indexes for ERPNext PostgreSQL
+-- ============================================================
+-- Problem: ERPNext v16 PostgreSQL migration does not create
+-- indexes on 'customer' / 'party' columns. This causes full
+-- table scans on tables with 100k-1M+ rows, resulting in
+-- 5+ second page loads in Ops client detail page.
+--
+-- Impact: Sales Invoice query dropped from 1,248ms to 28ms
+-- (EXPLAIN ANALYZE: 378ms → 0.36ms, 1000x improvement)
+--
+-- Run: docker exec erpnext-db-1 psql -U postgres -d
-f /path/to/this.sql
+-- Or run each CREATE INDEX statement individually.
+--
+-- All indexes use CONCURRENTLY to avoid locking production tables.
+-- Run each statement in its own transaction (outside BEGIN/COMMIT).
+-- ============================================================
+
+-- Customer lookup on Sales Invoice (630k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_si_customer
+ ON "tabSales Invoice" (customer);
+
+-- Composite index for sorted invoice listing per customer
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_si_posting
+ ON "tabSales Invoice" (customer, posting_date DESC);
+
+-- Party lookup on Payment Entry (344k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pe_party
+ ON "tabPayment Entry" (party);
+
+-- Composite index for sorted payment listing per party
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pe_posting
+ ON "tabPayment Entry" (party, posting_date DESC);
+
+-- Reference lookup on Comment (1.07M+ rows)
+-- Used by: ticket comments, invoice notes, customer memos
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_comment_ref
+ ON "tabComment" (reference_doctype, reference_name);
+
+-- Customer lookup on Issue (243k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_issue_customer
+ ON "tabIssue" (customer);
+
+-- Customer lookup on Service Location (17k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sl_customer
+ ON "tabService Location" (customer);
+
+-- Customer lookup on Service Subscription (40k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ss_customer
+ ON "tabService Subscription" (customer);
+
+-- Customer lookup on Service Equipment (10k+ rows)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_se_customer
+ ON "tabService Equipment" (customer);
+
+-- Update planner statistics after index creation
+ANALYZE "tabSales Invoice";
+ANALYZE "tabPayment Entry";
+ANALYZE "tabComment";
+ANALYZE "tabIssue";
+ANALYZE "tabService Location";
+ANALYZE "tabService Subscription";
+ANALYZE "tabService Equipment";
diff --git a/scripts/migration/import_services_and_enrich_customers.py b/scripts/migration/import_services_and_enrich_customers.py
index 408076d..88e4d86 100644
--- a/scripts/migration/import_services_and_enrich_customers.py
+++ b/scripts/migration/import_services_and_enrich_customers.py
@@ -7,6 +7,7 @@ Also adds custom fields to Service Subscription for RADIUS/legacy data.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/import_services_and_enrich_customers.py
"""
+import html
import frappe
import pymysql
import os
@@ -221,7 +222,7 @@ for svc in services:
service_category = PROD_CAT_MAP.get(prod_cat, "Autre")
# Plan name
- plan_name = prod_names.get(svc["product_id"], svc["sku"] or "Unknown")
+ plan_name = html.unescape(prod_names.get(svc["product_id"], svc["sku"] or "Unknown"))
# Speed (legacy stores in kbps, convert to Mbps)
speed_down = 0
diff --git a/scripts/migration/reimport_subscriptions.py b/scripts/migration/reimport_subscriptions.py
index 495bcc4..e2fd5d1 100644
--- a/scripts/migration/reimport_subscriptions.py
+++ b/scripts/migration/reimport_subscriptions.py
@@ -20,6 +20,7 @@ import sys
import os
import uuid
import time
+import html
from datetime import datetime, timezone
os.chdir("/home/frappe/frappe-bench/sites")
@@ -213,8 +214,8 @@ for i, ss in enumerate(ss_rows):
# Category
category = ss.get("service_category") or ""
- # Description: plan_name from SS
- description = ss.get("plan_name") or ""
+ # Description: plan_name from SS (unescape HTML entities from legacy data)
+ description = html.unescape(ss.get("plan_name") or "")
sub_id = uid("SUB-")
diff --git a/scripts/migration/rename_customers_c_prefix.py b/scripts/migration/rename_customers_c_prefix.py
new file mode 100644
index 0000000..68c4ad4
--- /dev/null
+++ b/scripts/migration/rename_customers_c_prefix.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+"""
+Prepend C- to all customer names.
+
+Current state: customers have raw legacy_customer_id as name
+ e.g. LPB4, 114796350603272, DOMIL5149490230
+
+After: C-LPB4, C-114796350603272, C-DOMIL5149490230
+
+New customers: C-10000000034941+ (naming_series C-.##############)
+
+Two-phase rename to avoid PK collisions:
+ Phase A: old → _TMP_C-old
+ Phase B: _TMP_C-old → C-old
+"""
+import os, sys, time
+
+os.chdir("/home/frappe/frappe-bench/sites")
+import frappe
+frappe.init(site="erp.gigafibre.ca", sites_path=".")
+frappe.connect()
+frappe.local.flags.ignore_permissions = True
+print(f"Connected: {frappe.local.site}")
+
+DRY_RUN = "--dry-run" in sys.argv
+if DRY_RUN:
+ print("*** DRY RUN ***")
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 1: Build mapping — prepend C- to every customer name
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("STEP 1: BUILD MAPPING")
+print("=" * 60)
+
+customers = frappe.db.sql("""
+ SELECT name FROM "tabCustomer" ORDER BY name
+""", as_dict=True)
+
+mapping = {} # old → new
+new_used = set()
+skipped = 0
+
+for c in customers:
+ old = c["name"]
+ # Skip if already has C- prefix (idempotent)
+ if old.startswith("C-"):
+ skipped += 1
+ continue
+ new = f"C-{old}"
+ if new in new_used:
+ new = f"{new}-dup{len(new_used)}"
+ new_used.add(new)
+ mapping[old] = new
+
+print(f"Total customers: {len(customers)}")
+print(f"Will rename: {len(mapping)}")
+print(f"Already C- prefixed (skip): {skipped}")
+
+# Samples
+for old, new in list(mapping.items())[:15]:
+ print(f" {old:40s} → {new}")
+
+if DRY_RUN:
+ print("\n*** DRY RUN complete ***")
+ sys.exit(0)
+
+if not mapping:
+ print("Nothing to rename!")
+ sys.exit(0)
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 2: Create temp mapping table
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("STEP 2: TEMP MAPPING TABLE")
+print("=" * 60)
+
+frappe.db.sql('DROP TABLE IF EXISTS _cust_cpre_map')
+frappe.db.sql("""
+ CREATE TEMP TABLE _cust_cpre_map (
+ old_name VARCHAR(140) PRIMARY KEY,
+ new_name VARCHAR(140) NOT NULL
+ )
+""")
+items = list(mapping.items())
+for start in range(0, len(items), 1000):
+ batch = items[start:start + 1000]
+ frappe.db.sql(
+ "INSERT INTO _cust_cpre_map (old_name, new_name) VALUES " +
+ ",".join(["(%s, %s)"] * len(batch)),
+ [v for pair in batch for v in pair]
+ )
+frappe.db.commit()
+print(f" {len(mapping)} mappings loaded")
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 3: Update all FK references
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("STEP 3: UPDATE FK REFERENCES")
+print("=" * 60)
+
+fk_tables = [
+ ("tabSales Invoice", "customer", ""),
+ ("tabPayment Entry", "party", ""),
+ ("tabGL Entry", "party", ""),
+ ("tabPayment Ledger Entry", "party", ""),
+ ("tabIssue", "customer", ""),
+ ("tabService Location", "customer", ""),
+ ("tabService Subscription", "customer", ""),
+ ("tabSubscription", "party", ""),
+ ("tabService Equipment", "customer", ""),
+ ("tabDispatch Job", "customer", ""),
+ ("tabDynamic Link", "link_name", "AND t.link_doctype = 'Customer'"),
+ ("tabComment", "reference_name", "AND t.reference_doctype = 'Customer'"),
+ ("tabCommunication", "reference_name", "AND t.reference_doctype = 'Customer'"),
+ ("tabVersion", "docname", "AND t.ref_doctype = 'Customer'"),
+]
+
+for table, col, extra in fk_tables:
+ t0 = time.time()
+ try:
+ frappe.db.sql(f"""
+ UPDATE "{table}" t SET "{col}" = m.new_name
+ FROM _cust_cpre_map m
+ WHERE t."{col}" = m.old_name {extra}
+ """)
+ frappe.db.commit()
+ print(f" {table:35s} {col:20s} [{time.time()-t0:.1f}s]")
+ except Exception as e:
+ frappe.db.rollback()
+ err = str(e)[:80]
+ if "does not exist" not in err:
+ print(f" {table:35s} ERR: {err}")
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 4: Two-phase rename customers
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("STEP 4: RENAME CUSTOMERS (two-phase)")
+print("=" * 60)
+
+# Phase A: old → _TMP_C-old
+t0 = time.time()
+for start in range(0, len(items), 500):
+ batch = items[start:start + 500]
+ cases = " ".join(f"WHEN '{old}' THEN '_TMP_{new}'" for old, new in batch)
+ old_names = "','".join(old for old, _ in batch)
+ frappe.db.sql(f"""
+ UPDATE "tabCustomer"
+ SET name = CASE name {cases} END
+ WHERE name IN ('{old_names}')
+ """)
+ if (start + 500) % 5000 < 500:
+ frappe.db.commit()
+ print(f" A: {min(start+500, len(items))}/{len(items)}")
+frappe.db.commit()
+print(f" Phase A done [{time.time()-t0:.1f}s]")
+
+# Phase B: _TMP_C-old → C-old
+t0 = time.time()
+for start in range(0, len(items), 500):
+ batch = items[start:start + 500]
+ cases = " ".join(f"WHEN '_TMP_{new}' THEN '{new}'" for _, new in batch)
+ temp_names = "','".join(f"_TMP_{new}" for _, new in batch)
+ frappe.db.sql(f"""
+ UPDATE "tabCustomer"
+ SET name = CASE name {cases} END
+ WHERE name IN ('{temp_names}')
+ """)
+ if (start + 500) % 5000 < 500:
+ frappe.db.commit()
+ print(f" B: {min(start+500, len(items))}/{len(items)}")
+frappe.db.commit()
+print(f" Phase B done [{time.time()-t0:.1f}s]")
+
+frappe.db.sql('DROP TABLE IF EXISTS _cust_cpre_map')
+frappe.db.commit()
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 5: Set naming series — C-.############## → C-10000000034941+
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("STEP 5: NAMING SERIES")
+print("=" * 60)
+
+# Update Customer doctype naming_series options
+frappe.db.sql("""
+ UPDATE "tabDocField"
+ SET options = 'C-.##############', "default" = 'C-.##############'
+ WHERE parent = 'Customer' AND fieldname = 'naming_series'
+""")
+frappe.db.commit()
+print(" naming_series options: C-.##############")
+
+# Remove old C series (no dash), set new C- series
+# Counter 10000000034940 means next = C-10000000034941
+frappe.db.sql("DELETE FROM \"tabSeries\" WHERE name = 'C'")
+
+series = frappe.db.sql("SELECT current FROM \"tabSeries\" WHERE name = 'C-'", as_dict=True)
+if series:
+ frappe.db.sql("UPDATE \"tabSeries\" SET current = 10000000034940 WHERE name = 'C-'")
+else:
+ frappe.db.sql("INSERT INTO \"tabSeries\" (name, current) VALUES ('C-', 10000000034940)")
+frappe.db.commit()
+print(" C- series counter: 10000000034940")
+print(" Next new customer: C-10000000034941")
+
+
+# ═══════════════════════════════════════════════════════════════
+# STEP 6: Verify
+# ═══════════════════════════════════════════════════════════════
+print("\n" + "=" * 60)
+print("VERIFICATION")
+print("=" * 60)
+
+total = frappe.db.sql('SELECT COUNT(*) FROM "tabCustomer"')[0][0]
+with_c = frappe.db.sql("SELECT COUNT(*) FROM \"tabCustomer\" WHERE name LIKE 'C-%%'")[0][0]
+without_c = total - with_c
+print(f" Customers total: {total}")
+print(f" With C- prefix: {with_c}")
+print(f" Without C- prefix: {without_c} (should be 0)")
+
+# Samples
+samples = frappe.db.sql("""
+ SELECT name, customer_name, legacy_customer_id
+ FROM "tabCustomer" ORDER BY name LIMIT 15
+""", as_dict=True)
+for s in samples:
+ print(f" {s['name']:30s} {s.get('legacy_customer_id',''):20s} {s['customer_name']}")
+
+# Spot checks
+for lid, label in [(4, "LPB4"), (13814, "Vegpro")]:
+ c = frappe.db.sql('SELECT name, legacy_customer_id FROM "tabCustomer" WHERE legacy_account_id = %s', (lid,), as_dict=True)
+ if c:
+ print(f" {label}: {c[0]['name']} (bank ref: {c[0].get('legacy_customer_id','')})")
+
+# FK checks
+for table, col in [("tabSales Invoice", "customer"), ("tabSubscription", "party"), ("tabIssue", "customer")]:
+ orphans = frappe.db.sql(f"""
+ SELECT COUNT(*) FROM "{table}" t
+ WHERE t."{col}" IS NOT NULL AND t."{col}" != ''
+ AND NOT EXISTS (SELECT 1 FROM "tabCustomer" c WHERE c.name = t."{col}")
+ """)[0][0]
+ print(f" {table}.{col}: {'OK ✓' if orphans == 0 else f'ORPHANS: {orphans}'}")
+
+frappe.clear_cache()
+print("\nDone!")
diff --git a/scripts/server_bulk_submit.py b/scripts/server_bulk_submit.py
new file mode 100644
index 0000000..9a81808
--- /dev/null
+++ b/scripts/server_bulk_submit.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""
+Server-side bulk submit script for ERPNext migration data.
+Run this INSIDE the erpnext-backend container via bench console.
+
+Usage:
+ 1. SSH into server: ssh root@96.125.196.67
+ 2. Enter container: docker exec -it erpnext-backend-1 bash
+ 3. Go to bench: cd /home/frappe/frappe-bench
+ 4. Run: bench --site all execute scripts.server_bulk_submit.run
+
+ OR copy this file and run with bench console:
+ bench --site erp.gigafibre.ca console
+ Then paste the code below.
+
+BEFORE RUNNING: Fix the PostgreSQL GROUP BY bug first! (see fix_ple_postgres.sh)
+"""
+
+# === Paste this into bench console ===
+
+import frappe
+
+def run():
+ """Main entry point for bench execute"""
+ site = frappe.local.site
+ print(f"Running on site: {site}")
+
+ # Mute all emails and notifications
+ frappe.flags.mute_emails = True
+ frappe.flags.mute_notifications = True
+ frappe.flags.in_import = True
+
+ # Step 1: Enable disabled items
+ print("\n═══ Step 1: Enable disabled Items ═══")
+ disabled_items = frappe.get_all("Item", filters={"disabled": 1}, pluck="name")
+ print(f" Found {len(disabled_items)} disabled items")
+ for item_name in disabled_items:
+ frappe.db.set_value("Item", item_name, "disabled", 0, update_modified=False)
+ frappe.db.commit()
+ print(f" Enabled all {len(disabled_items)} items")
+
+ # Step 2: Submit Sales Invoices
+ print("\n═══ Step 2: Submit Sales Invoices ═══")
+ draft_invoices = frappe.get_all(
+ "Sales Invoice",
+ filters={"docstatus": 0},
+ pluck="name",
+ order_by="posting_date asc",
+ limit_page_length=0
+ )
+ total_inv = len(draft_invoices)
+ print(f" Total draft invoices: {total_inv}")
+
+ ok, fail = 0, 0
+ errors = []
+ for i, inv_name in enumerate(draft_invoices):
+ try:
+ inv = frappe.get_doc("Sales Invoice", inv_name)
+ inv.set_posting_time = 1 # keep original posting_date
+ inv.flags.ignore_permissions = True
+ inv.flags.mute_emails = True
+ inv.flags.ignore_notifications = True
+ inv.submit()
+
+ ok += 1
+ if ok % 100 == 0:
+ frappe.db.commit()
+ print(f" Progress: {ok + fail}/{total_inv} (ok={ok}, fail={fail})")
+ except Exception as e:
+ fail += 1
+ frappe.db.rollback()
+ if len(errors) < 30:
+ errors.append(f"{inv_name}: {str(e)[:150]}")
+
+ frappe.db.commit()
+ print(f" Done: submitted={ok}, failed={fail}")
+ if errors:
+ print(f" Errors (first {len(errors)}):")
+ for e in errors:
+ print(f" {e}")
+
+ # Step 3: Submit Payment Entries
+ print("\n═══ Step 3: Submit Payment Entries ═══")
+ draft_payments = frappe.get_all(
+ "Payment Entry",
+ filters={"docstatus": 0},
+ pluck="name",
+ order_by="posting_date asc",
+ limit_page_length=0
+ )
+ total_pe = len(draft_payments)
+ print(f" Total draft payments: {total_pe}")
+
+ ok, fail = 0, 0
+ errors = []
+ for i, pe_name in enumerate(draft_payments):
+ try:
+ pe = frappe.get_doc("Payment Entry", pe_name)
+ pe.flags.ignore_permissions = True
+ pe.flags.mute_emails = True
+ pe.flags.ignore_notifications = True
+ pe.submit()
+
+ ok += 1
+ if ok % 100 == 0:
+ frappe.db.commit()
+ print(f" Progress: {ok + fail}/{total_pe} (ok={ok}, fail={fail})")
+ except Exception as e:
+ fail += 1
+ frappe.db.rollback()
+ if len(errors) < 30:
+ errors.append(f"{pe_name}: {str(e)[:150]}")
+
+ frappe.db.commit()
+ print(f" Done: submitted={ok}, failed={fail}")
+ if errors:
+ print(f" Errors (first {len(errors)}):")
+ for e in errors:
+ print(f" {e}")
+
+ # Unmute
+ frappe.flags.mute_emails = False
+ frappe.flags.mute_notifications = False
+ frappe.flags.in_import = False
+
+ print("\n✓ All done!")
diff --git a/services/targo-hub/docker-compose.yml b/services/targo-hub/docker-compose.yml
new file mode 100644
index 0000000..c291050
--- /dev/null
+++ b/services/targo-hub/docker-compose.yml
@@ -0,0 +1,33 @@
+services:
+ targo-hub:
+ image: node:20-alpine
+ container_name: targo-hub
+ working_dir: /app
+ volumes:
+ - ./server.js:/app/server.js:ro
+ - ./package.json:/app/package.json:ro
+ command: node server.js
+ env_file: .env
+ restart: unless-stopped
+ networks:
+ - proxy
+ - erpnext_erpnext
+ labels:
+ - "traefik.enable=true"
+ - "traefik.docker.network=proxy"
+
+ # Main router — webhooks + send + health (no auth)
+ - "traefik.http.routers.targo-hub.rule=Host(`msg.gigafibre.ca`)"
+ - "traefik.http.routers.targo-hub.entrypoints=websecure"
+ - "traefik.http.routers.targo-hub.tls.certresolver=letsencrypt"
+ - "traefik.http.services.targo-hub.loadbalancer.server.port=3300"
+
+ # Disable response buffering for SSE
+ - "traefik.http.middlewares.sse-headers.headers.customresponseheaders.X-Accel-Buffering=no"
+ - "traefik.http.routers.targo-hub.middlewares=sse-headers"
+
+networks:
+ proxy:
+ external: true
+ erpnext_erpnext:
+ external: true
diff --git a/services/targo-hub/package.json b/services/targo-hub/package.json
new file mode 100644
index 0000000..99d1ab4
--- /dev/null
+++ b/services/targo-hub/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "targo-hub",
+ "version": "1.0.0",
+ "description": "SSE relay + unified message hub for Targo/Gigafibre",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "twilio": "^5.5.0",
+ "pg": "^8.13.0"
+ }
+}
diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js
new file mode 100644
index 0000000..6b1de4d
--- /dev/null
+++ b/services/targo-hub/server.js
@@ -0,0 +1,1038 @@
+/**
+ * targo-hub — Lightweight SSE relay for real-time Communication events.
+ *
+ * Endpoints:
+ * GET /sse?topics=customer:C-LPB4,customer:C-114796350603272
+ * → SSE stream, authenticated via X-Authentik-Email header (Traefik injects it)
+ *
+ * POST /broadcast
+ * → Push an event to all SSE clients subscribed to matching topics
+ * → Auth: Bearer token (HUB_INTERNAL_TOKEN)
+ * → Body: { topic, event, data }
+ *
+ * POST /webhook/twilio/sms-incoming
+ * → Receive Twilio inbound SMS, log to ERPNext, broadcast SSE
+ *
+ * POST /webhook/twilio/sms-status
+ * → Receive Twilio delivery status updates
+ *
+ * GET /health
+ * → Health check
+ *
+ * 3CX Call Log Poller:
+ * → Polls 3CX xAPI every 30s for new completed calls
+ * → Logs to ERPNext Communication, broadcasts SSE
+ */
+
+const http = require('http')
+const https = require('https')
+const { URL } = require('url')
+
+// ── Config ──
+const PORT = parseInt(process.env.PORT || '3300', 10)
+const INTERNAL_TOKEN = process.env.HUB_INTERNAL_TOKEN || ''
+const ERP_URL = process.env.ERP_URL || 'http://erpnext-backend:8000'
+const ERP_TOKEN = process.env.ERP_TOKEN || ''
+const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || ''
+const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || ''
+const TWILIO_FROM = process.env.TWILIO_FROM || ''
+
+// 3CX Config (DISABLED by default — set PBX_ENABLED=1 to re-enable)
+const PBX_ENABLED = process.env.PBX_ENABLED === '1'
+const PBX_URL = process.env.PBX_URL || 'https://targopbx.3cx.ca'
+const PBX_USER = process.env.PBX_USER || ''
+const PBX_PASS = process.env.PBX_PASS || ''
+const PBX_POLL_INTERVAL = parseInt(process.env.PBX_POLL_INTERVAL || '30000', 10) // 30s
+
+// Twilio Voice Config
+const TWILIO_API_KEY = process.env.TWILIO_API_KEY || ''
+const TWILIO_API_SECRET = process.env.TWILIO_API_SECRET || ''
+const TWILIO_TWIML_APP_SID = process.env.TWILIO_TWIML_APP_SID || ''
+
+// Fonoster/Routr DB Config (direct PostgreSQL access)
+const ROUTR_DB_URL = process.env.ROUTR_DB_URL || ''
+const FNIDENTITY_DB_URL = process.env.FNIDENTITY_DB_URL || ''
+
+// ── SSE Client Registry ──
+// Map>
+const subscribers = new Map()
+let clientIdSeq = 0
+
+function addClient (topics, res, email) {
+ const id = ++clientIdSeq
+ const client = { id, res, email, topics }
+ for (const t of topics) {
+ if (!subscribers.has(t)) subscribers.set(t, new Set())
+ subscribers.get(t).add(client)
+ }
+ // Remove on disconnect
+ res.on('close', () => {
+ for (const t of topics) {
+ const set = subscribers.get(t)
+ if (set) {
+ set.delete(client)
+ if (set.size === 0) subscribers.delete(t)
+ }
+ }
+ log(`SSE client #${id} disconnected (${email})`)
+ })
+ log(`SSE client #${id} connected (${email}) topics=[${topics.join(',')}]`)
+ return id
+}
+
+function broadcast (topic, event, data) {
+ const set = subscribers.get(topic)
+ if (!set || set.size === 0) return 0
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
+ let count = 0
+ for (const client of set) {
+ try {
+ client.res.write(payload)
+ count++
+ } catch {
+ // Client gone, will be cleaned up on close
+ }
+ }
+ return count
+}
+
+function broadcastAll (event, data) {
+ // Broadcast to ALL connected clients regardless of topic
+ const sent = new Set()
+ let count = 0
+ for (const [, set] of subscribers) {
+ for (const client of set) {
+ if (!sent.has(client.id)) {
+ sent.add(client.id)
+ try {
+ client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
+ count++
+ } catch {}
+ }
+ }
+ }
+ return count
+}
+
+// ── ERPNext helpers ──
+const ERP_SITE = process.env.ERP_SITE || 'erp.gigafibre.ca'
+
+function erpFetch (path, opts = {}) {
+ const url = ERP_URL + path
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url)
+ const reqOpts = {
+ hostname: parsed.hostname,
+ port: parsed.port || 8000,
+ path: parsed.pathname + parsed.search,
+ method: opts.method || 'GET',
+ headers: {
+ Host: ERP_SITE, // Required: Frappe multi-tenant routing needs the site name
+ Authorization: 'token ' + ERP_TOKEN,
+ 'Content-Type': 'application/json',
+ ...opts.headers,
+ },
+ }
+ const req = http.request(reqOpts, (res) => {
+ let body = ''
+ res.on('data', c => body += c)
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
+ catch { resolve({ status: res.statusCode, data: body }) }
+ })
+ })
+ req.on('error', reject)
+ if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body))
+ req.end()
+ })
+}
+
+async function lookupCustomerByPhone (phone) {
+ // Normalize to last 10 digits
+ const digits = phone.replace(/\D/g, '').slice(-10)
+ const fields = JSON.stringify(['name', 'customer_name', 'cell_phone', 'tel_home', 'tel_office'])
+
+ // Search across all phone fields: cell_phone, tel_home, tel_office
+ for (const field of ['cell_phone', 'tel_home', 'tel_office']) {
+ const filters = JSON.stringify([[field, 'like', '%' + digits]])
+ const path = `/api/resource/Customer?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=1`
+ try {
+ const res = await erpFetch(path)
+ if (res.status === 200 && res.data.data && res.data.data.length > 0) {
+ return res.data.data[0]
+ }
+ } catch (e) {
+ log('lookupCustomerByPhone error on ' + field + ':', e.message)
+ }
+ }
+ return null
+}
+
+async function createCommunication (fields) {
+ return erpFetch('/api/resource/Communication', {
+ method: 'POST',
+ body: JSON.stringify(fields),
+ })
+}
+
+// ── Request body parser ──
+function parseBody (req) {
+ return new Promise((resolve, reject) => {
+ const chunks = []
+ req.on('data', c => chunks.push(c))
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString()
+ const ct = (req.headers['content-type'] || '').toLowerCase()
+ if (ct.includes('application/json')) {
+ try { resolve(JSON.parse(raw)) } catch { resolve({}) }
+ } else if (ct.includes('urlencoded')) {
+ // Twilio sends application/x-www-form-urlencoded
+ const params = new URLSearchParams(raw)
+ const obj = {}
+ for (const [k, v] of params) obj[k] = v
+ resolve(obj)
+ } else {
+ resolve(raw)
+ }
+ })
+ req.on('error', reject)
+ })
+}
+
+// ── Logging ──
+function log (...args) {
+ console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...args)
+}
+
+// ── HTTP Server ──
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, `http://localhost:${PORT}`)
+ const path = url.pathname
+ const method = req.method
+
+ // CORS headers (for browser SSE + POST from ops/client apps)
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authentik-Email, X-Authentik-Groups')
+
+ if (method === 'OPTIONS') {
+ res.writeHead(204)
+ return res.end()
+ }
+
+ try {
+ // ─── Health ───
+ if (path === '/health') {
+ const clientCount = new Set()
+ for (const [, set] of subscribers) for (const c of set) clientCount.add(c.id)
+ return json(res, 200, {
+ ok: true,
+ clients: clientCount.size,
+ topics: subscribers.size,
+ uptime: Math.floor(process.uptime()),
+ })
+ }
+
+ // ─── SSE endpoint ───
+ if (path === '/sse' && method === 'GET') {
+ const email = req.headers['x-authentik-email'] || 'anonymous'
+ const topicsParam = url.searchParams.get('topics') || ''
+ const topics = topicsParam.split(',').map(t => t.trim()).filter(Boolean)
+
+ if (!topics.length) {
+ return json(res, 400, { error: 'Missing topics parameter' })
+ }
+
+ // Set SSE headers
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'X-Accel-Buffering': 'no', // Disable nginx/traefik buffering
+ })
+
+ // Send initial comment to establish connection
+ res.write(': connected\n\n')
+
+ const clientId = addClient(topics, res, email)
+
+ // Keepalive ping every 25s
+ const keepalive = setInterval(() => {
+ try { res.write(': ping\n\n') } catch { clearInterval(keepalive) }
+ }, 25000)
+
+ res.on('close', () => clearInterval(keepalive))
+ return
+ }
+
+ // ─── Broadcast (internal) ───
+ if (path === '/broadcast' && method === 'POST') {
+ // Auth check
+ const auth = req.headers.authorization || ''
+ if (INTERNAL_TOKEN && auth !== 'Bearer ' + INTERNAL_TOKEN) {
+ return json(res, 401, { error: 'Unauthorized' })
+ }
+
+ const body = await parseBody(req)
+ const { topic, event, data } = body
+
+ if (!topic || !event) {
+ return json(res, 400, { error: 'Missing topic or event' })
+ }
+
+ const count = broadcast(topic, event || 'message', data || {})
+ return json(res, 200, { ok: true, delivered: count })
+ }
+
+ // ─── Twilio SMS Incoming ───
+ if (path === '/webhook/twilio/sms-incoming' && method === 'POST') {
+ const body = await parseBody(req)
+ const from = body.From || ''
+ const to = body.To || ''
+ const text = body.Body || ''
+ const sid = body.MessageSid || ''
+
+ log(`SMS IN: ${from} → ${to}: ${text.substring(0, 50)}...`)
+
+ // Respond to Twilio immediately (they expect TwiML or 200)
+ res.writeHead(200, { 'Content-Type': 'text/xml' })
+ res.end('')
+
+ // Process async: lookup customer, log, broadcast
+ setImmediate(async () => {
+ try {
+ const customer = await lookupCustomerByPhone(from)
+ const customerName = customer ? customer.name : null
+ const customerLabel = customer ? customer.customer_name : 'Inconnu'
+
+ // Log Communication in ERPNext
+ if (customerName) {
+ await createCommunication({
+ communication_type: 'Communication',
+ communication_medium: 'SMS',
+ sent_or_received: 'Received',
+ sender: 'sms@gigafibre.ca',
+ sender_full_name: customerLabel,
+ phone_no: from,
+ content: text,
+ subject: 'SMS from ' + from,
+ reference_doctype: 'Customer',
+ reference_name: customerName,
+ message_id: sid,
+ status: 'Open',
+ })
+ }
+
+ // Broadcast SSE event
+ const eventData = {
+ type: 'sms',
+ direction: 'in',
+ customer: customerName,
+ customer_name: customerLabel,
+ phone: from,
+ text,
+ sid,
+ ts: new Date().toISOString(),
+ }
+
+ let n = 0
+ if (customerName) {
+ n = broadcast('customer:' + customerName, 'message', eventData)
+ }
+ // Also broadcast to global topic for dispatch/monitoring
+ broadcastAll('sms-incoming', eventData)
+
+ log(`SMS logged: ${from} → ${customerName || 'UNKNOWN'} (${sid}) broadcast=${n}`)
+ } catch (e) {
+ log('SMS processing error:', e.message)
+ }
+ })
+ return
+ }
+
+ // ─── Twilio SMS Status ───
+ if (path === '/webhook/twilio/sms-status' && method === 'POST') {
+ const body = await parseBody(req)
+ const sid = body.MessageSid || body.SmsSid || ''
+ const status = body.MessageStatus || body.SmsStatus || ''
+
+ log(`SMS STATUS: ${sid} → ${status}`)
+
+ // Respond immediately
+ res.writeHead(200, { 'Content-Type': 'text/xml' })
+ res.end('')
+
+ // Broadcast status update
+ setImmediate(() => {
+ broadcastAll('sms-status', { sid, status, ts: new Date().toISOString() })
+ })
+ return
+ }
+
+ // ─── Send SMS (outbound, called from ops/client apps) ───
+ if (path === '/send/sms' && method === 'POST') {
+ const body = await parseBody(req)
+ const { phone, message, customer } = body
+
+ if (!phone || !message) {
+ return json(res, 400, { error: 'Missing phone or message' })
+ }
+
+ // Send via Twilio
+ const twilioData = new URLSearchParams({
+ To: phone.startsWith('+') ? phone : '+' + phone,
+ From: TWILIO_FROM,
+ Body: message,
+ StatusCallback: (process.env.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/webhook/twilio/sms-status',
+ })
+
+ const twilioRes = await new Promise((resolve, reject) => {
+ const authStr = Buffer.from(TWILIO_ACCOUNT_SID + ':' + TWILIO_AUTH_TOKEN).toString('base64')
+ const twilioReq = require('https').request({
+ hostname: 'api.twilio.com',
+ path: `/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`,
+ method: 'POST',
+ headers: {
+ Authorization: 'Basic ' + authStr,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ }, (res) => {
+ let body = ''
+ res.on('data', c => body += c)
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
+ catch { resolve({ status: res.statusCode, data: body }) }
+ })
+ })
+ twilioReq.on('error', reject)
+ twilioReq.write(twilioData.toString())
+ twilioReq.end()
+ })
+
+ if (twilioRes.status >= 400) {
+ log('Twilio send error:', twilioRes.data)
+ return json(res, 502, { ok: false, error: 'Twilio error', details: twilioRes.data })
+ }
+
+ const sid = twilioRes.data.sid || ''
+ log(`SMS OUT: → ${phone}: ${message.substring(0, 50)}... (${sid})`)
+
+ // Log Communication in ERPNext
+ if (customer) {
+ await createCommunication({
+ communication_type: 'Communication',
+ communication_medium: 'SMS',
+ sent_or_received: 'Sent',
+ sender: 'sms@gigafibre.ca',
+ sender_full_name: 'Targo Ops',
+ phone_no: phone,
+ content: message,
+ subject: 'SMS to ' + phone,
+ reference_doctype: 'Customer',
+ reference_name: customer,
+ message_id: sid,
+ status: 'Linked',
+ })
+
+ // Broadcast SSE
+ broadcast('customer:' + customer, 'message', {
+ type: 'sms',
+ direction: 'out',
+ customer,
+ phone,
+ text: message,
+ sid,
+ ts: new Date().toISOString(),
+ })
+ }
+
+ return json(res, 200, { ok: true, sid })
+ }
+
+ // ─── Twilio Voice: Generate Access Token ───
+ if (path === '/voice/token' && method === 'GET') {
+ if (!TWILIO_API_KEY || !TWILIO_API_SECRET || !TWILIO_TWIML_APP_SID) {
+ return json(res, 503, { error: 'Twilio Voice not configured' })
+ }
+
+ const identity = url.searchParams.get('identity') || req.headers['x-authentik-email'] || 'ops-agent'
+
+ try {
+ const twilio = require('twilio')
+ const AccessToken = twilio.jwt.AccessToken
+ const VoiceGrant = AccessToken.VoiceGrant
+
+ const token = new AccessToken(
+ TWILIO_ACCOUNT_SID,
+ TWILIO_API_KEY,
+ TWILIO_API_SECRET,
+ { identity, ttl: 3600 }
+ )
+
+ const grant = new VoiceGrant({
+ outgoingApplicationSid: TWILIO_TWIML_APP_SID,
+ incomingAllow: true,
+ })
+ token.addGrant(grant)
+
+ log(`Voice token generated for ${identity}`)
+ return json(res, 200, { token: token.toJwt(), identity })
+ } catch (e) {
+ log('Voice token error:', e.message)
+ return json(res, 500, { error: 'Token generation failed: ' + e.message })
+ }
+ }
+
+ // ─── Fonoster SIP: Get SIP config for WebRTC client ───
+ if (path === '/voice/sip-config' && method === 'GET') {
+ // Return SIP credentials for the browser client
+ // These are configured via env vars or fetched from Fonoster API
+ const sipConfig = {
+ wssUrl: process.env.SIP_WSS_URL || 'wss://voice.gigafibre.ca:5063',
+ domain: process.env.SIP_DOMAIN || 'voice.gigafibre.ca',
+ extension: process.env.SIP_EXTENSION || '1001',
+ authId: process.env.SIP_AUTH_ID || '1001',
+ authPassword: process.env.SIP_AUTH_PASSWORD || '',
+ displayName: process.env.SIP_DISPLAY_NAME || 'Targo Ops',
+ identity: process.env.SIP_EXTENSION || '1001',
+ }
+ return json(res, 200, sipConfig)
+ }
+
+ // ─── Twilio Voice: TwiML for outbound calls ───
+ if (path === '/voice/twiml' && method === 'POST') {
+ const body = await parseBody(req)
+ const to = body.To || body.phone || ''
+
+ log(`Voice TwiML: dialing ${to}`)
+
+ // Return TwiML to dial the number
+ const callerId = TWILIO_FROM || '+14382313838'
+ const twiml = `
+
+
+ ${to}
+
+`
+
+ res.writeHead(200, { 'Content-Type': 'text/xml' })
+ return res.end(twiml)
+ }
+
+ // ─── Twilio Voice: Call status callback ───
+ if (path === '/voice/status' && method === 'POST') {
+ const body = await parseBody(req)
+ const callSid = body.CallSid || ''
+ const callStatus = body.CallStatus || ''
+ const to = body.To || ''
+ const from = body.From || ''
+ const duration = parseInt(body.CallDuration || '0', 10)
+
+ log(`Voice STATUS: ${callSid} ${from}→${to} status=${callStatus} dur=${duration}s`)
+
+ res.writeHead(200, { 'Content-Type': 'text/xml' })
+ res.end('')
+
+ // On completed, log to ERPNext and broadcast
+ if (callStatus === 'completed' && duration > 0) {
+ setImmediate(async () => {
+ try {
+ const phone = to.startsWith('client:') ? from : to
+ const customer = await lookupCustomerByPhone(phone)
+ const customerName = customer ? customer.name : null
+
+ if (customerName) {
+ const durationMin = Math.floor(duration / 60)
+ const durationSec = duration % 60
+ const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
+
+ await createCommunication({
+ communication_type: 'Communication',
+ communication_medium: 'Phone',
+ sent_or_received: 'Sent',
+ sender: 'sms@gigafibre.ca',
+ sender_full_name: 'Targo Ops',
+ phone_no: phone,
+ content: `Appel vers ${phone} — Duree: ${durationStr}`,
+ subject: `Appel vers ${phone}`,
+ reference_doctype: 'Customer',
+ reference_name: customerName,
+ status: 'Linked',
+ })
+
+ broadcast('customer:' + customerName, 'call-event', {
+ type: 'call', event: 'completed', direction: 'out',
+ customer: customerName, phone, duration,
+ call_id: callSid, ts: new Date().toISOString(),
+ })
+ log(`Voice logged: ${phone} → ${customerName} (${durationStr})`)
+ }
+ } catch (e) {
+ log('Voice status processing error:', e.message)
+ }
+ })
+ }
+ return
+ }
+
+ // ─── 3CX Call Event Webhook (CRM Integration) ───
+ if (path === '/webhook/3cx/call-event' && method === 'POST') {
+ const body = await parseBody(req)
+
+ // 3CX CRM Integration sends: event_type, call_id, direction, caller, callee, ext, duration, status, etc.
+ 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() // inbound / outbound
+ 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
+
+ // Determine the remote phone number (not the extension)
+ 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})`)
+
+ // Respond immediately
+ json(res, 200, { ok: true })
+
+ // Process async: lookup customer, log communication, broadcast SSE
+ setImmediate(async () => {
+ try {
+ // Only log completed calls (not ringing/answered events) to avoid duplicates
+ 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 ? customer.name : null
+ const customerLabel = customer ? customer.customer_name : 'Inconnu'
+
+ // Log Communication in ERPNext for ended/completed calls
+ if (isCallEnd && customerName && duration > 0) {
+ const durationMin = Math.floor(duration / 60)
+ const durationSec = duration % 60
+ const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
+
+ await createCommunication({
+ communication_type: 'Communication',
+ communication_medium: 'Phone',
+ sent_or_received: isOutbound ? 'Sent' : 'Received',
+ sender: 'sms@gigafibre.ca',
+ sender_full_name: isOutbound ? 'Targo Ops' : customerLabel,
+ phone_no: remotePhone,
+ content: `Appel ${isOutbound ? 'sortant vers' : 'entrant de'} ${remotePhone} — Duree: ${durationStr}`,
+ subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
+ reference_doctype: 'Customer',
+ reference_name: customerName,
+ status: 'Linked',
+ })
+ }
+
+ // Broadcast SSE event for ALL call events (ringing, answered, ended)
+ 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(),
+ }
+
+ let n = 0
+ if (customerName) {
+ n = broadcast('customer:' + customerName, 'call-event', eventData)
+ }
+ // Also broadcast globally for dispatch monitoring
+ broadcastAll('call-event', eventData)
+
+ log(`3CX logged: ${remotePhone} → ${customerName || 'UNKNOWN'} (${callId}) broadcast=${n}`)
+ } catch (e) {
+ log('3CX call processing error:', e.message)
+ }
+ })
+ return
+ }
+
+ // ─── Fonoster Telephony Management API ───
+ // All /telephony/* endpoints manage the Fonoster CPaaS (trunks, agents, credentials, numbers, domains)
+ if (path.startsWith('/telephony/')) {
+ return handleTelephony(req, res, method, path, url)
+ }
+
+ // ─── 404 ───
+ json(res, 404, { error: 'Not found' })
+
+ } catch (e) {
+ log('ERROR:', e.message)
+ json(res, 500, { error: 'Internal error' })
+ }
+})
+
+function json (res, status, data) {
+ res.writeHead(status, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(data))
+}
+
+// ── Fonoster Telephony Management (Direct DB) ──
+// Access Routr + Fonoster identity PostgreSQL databases directly
+let routrPool = null
+let identityPool = null
+
+function getRoutrPool () {
+ if (!routrPool) {
+ const { Pool } = require('pg')
+ routrPool = new Pool({ connectionString: ROUTR_DB_URL, max: 3 })
+ routrPool.on('error', e => log('Routr DB pool error:', e.message))
+ }
+ return routrPool
+}
+
+function getIdentityPool () {
+ if (!identityPool) {
+ const { Pool } = require('pg')
+ identityPool = new Pool({ connectionString: FNIDENTITY_DB_URL, max: 3 })
+ identityPool.on('error', e => log('Identity DB pool error:', e.message))
+ }
+ return identityPool
+}
+
+// Routr resource tables
+const ROUTR_TABLES = {
+ trunks: 'trunks',
+ agents: 'agents',
+ credentials: 'credentials',
+ numbers: 'numbers',
+ domains: 'domains',
+ acls: 'access_control_lists',
+ peers: 'peers',
+}
+
+// Identity resource tables
+const IDENTITY_TABLES = {
+ workspaces: 'workspaces',
+ users: 'users',
+}
+
+async function handleTelephony (req, res, method, path, url) {
+ try {
+ const parts = path.replace('/telephony/', '').split('/').filter(Boolean)
+ const resource = parts[0]
+ const ref = parts[1] || null
+
+ // GET /telephony/overview — Dashboard summary
+ if (resource === 'overview' && method === 'GET') {
+ const rPool = getRoutrPool()
+ const iPool = getIdentityPool()
+ const [trunks, agents, creds, numbers, domains, peers, workspaces] = await Promise.all([
+ rPool.query('SELECT COUNT(*) FROM trunks'),
+ rPool.query('SELECT COUNT(*) FROM agents'),
+ rPool.query('SELECT COUNT(*) FROM credentials'),
+ rPool.query('SELECT COUNT(*) FROM numbers'),
+ rPool.query('SELECT COUNT(*) FROM domains'),
+ rPool.query('SELECT COUNT(*) FROM peers'),
+ iPool.query('SELECT COUNT(*) FROM workspaces'),
+ ])
+ return json(res, 200, {
+ trunks: parseInt(trunks.rows[0].count),
+ agents: parseInt(agents.rows[0].count),
+ credentials: parseInt(creds.rows[0].count),
+ numbers: parseInt(numbers.rows[0].count),
+ domains: parseInt(domains.rows[0].count),
+ peers: parseInt(peers.rows[0].count),
+ workspaces: parseInt(workspaces.rows[0].count),
+ })
+ }
+
+ // Determine which database
+ const isIdentity = IDENTITY_TABLES[resource]
+ const tableName = IDENTITY_TABLES[resource] || ROUTR_TABLES[resource]
+ if (!tableName) {
+ return json(res, 404, { error: `Unknown resource: ${resource}. Available: overview, ${[...Object.keys(ROUTR_TABLES), ...Object.keys(IDENTITY_TABLES)].join(', ')}` })
+ }
+
+ const pool = isIdentity ? getIdentityPool() : getRoutrPool()
+
+ // GET /telephony/{resource} — List all
+ if (method === 'GET' && !ref) {
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10)
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10)
+ const result = await pool.query(
+ `SELECT * FROM ${tableName} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
+ [limit, offset]
+ )
+ const count = await pool.query(`SELECT COUNT(*) FROM ${tableName}`)
+ return json(res, 200, { items: result.rows, total: parseInt(count.rows[0].count, 10) })
+ }
+
+ // GET /telephony/{resource}/{ref} — Get by ref
+ if (method === 'GET' && ref) {
+ const result = await pool.query(`SELECT * FROM ${tableName} WHERE ref = $1`, [ref])
+ if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
+ return json(res, 200, result.rows[0])
+ }
+
+ // POST /telephony/{resource} — Create
+ if (method === 'POST' && !ref) {
+ const body = await parseBody(req)
+ // Generate ref if not provided
+ if (!body.ref) {
+ const crypto = require('crypto')
+ body.ref = crypto.randomUUID()
+ }
+ if (!body.api_version) body.api_version = 'v2'
+ if (!body.created_at) body.created_at = new Date()
+ if (!body.updated_at) body.updated_at = new Date()
+
+ const keys = Object.keys(body)
+ const values = Object.values(body)
+ const placeholders = keys.map((_, i) => `$${i + 1}`)
+ const query = `INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`
+ const result = await pool.query(query, values)
+ log(`Telephony: created ${resource}/${result.rows[0].ref}`)
+ return json(res, 201, result.rows[0])
+ }
+
+ // PUT /telephony/{resource}/{ref} — Update
+ if (method === 'PUT' && ref) {
+ const body = await parseBody(req)
+ body.updated_at = new Date()
+ delete body.ref
+ delete body.created_at
+
+ const keys = Object.keys(body)
+ const values = Object.values(body)
+ const setClauses = keys.map((k, i) => `${k} = $${i + 1}`)
+ values.push(ref)
+ const query = `UPDATE ${tableName} SET ${setClauses.join(', ')} WHERE ref = $${values.length} RETURNING *`
+ const result = await pool.query(query, values)
+ if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
+ log(`Telephony: updated ${resource}/${ref}`)
+ return json(res, 200, result.rows[0])
+ }
+
+ // DELETE /telephony/{resource}/{ref} — Delete
+ if (method === 'DELETE' && ref) {
+ const result = await pool.query(`DELETE FROM ${tableName} WHERE ref = $1 RETURNING ref`, [ref])
+ if (result.rows.length === 0) return json(res, 404, { error: 'Not found' })
+ log(`Telephony: deleted ${resource}/${ref}`)
+ return json(res, 200, { ok: true, deleted: ref })
+ }
+
+ return json(res, 405, { error: 'Method not allowed' })
+ } catch (e) {
+ log('Telephony error:', e.message)
+ return json(res, 500, { error: e.message })
+ }
+}
+
+// ── 3CX Call Log Poller ──
+// Polls 3CX xAPI for recently completed calls, logs new ones to ERPNext + SSE
+
+let pbxToken = null
+let pbxTokenExpiry = 0
+const processedCallIds = new Set() // Track already-processed call IDs (in memory, survives poll cycles)
+let pbxPollTimer = null
+
+function httpsFetch (url, opts = {}) {
+ return new Promise((resolve, reject) => {
+ const parsed = new URL(url)
+ const reqOpts = {
+ hostname: parsed.hostname,
+ port: parsed.port || 443,
+ path: parsed.pathname + parsed.search,
+ method: opts.method || 'GET',
+ headers: opts.headers || {},
+ }
+ const req = https.request(reqOpts, (res) => {
+ let body = ''
+ res.on('data', c => body += c)
+ res.on('end', () => {
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }) }
+ catch { resolve({ status: res.statusCode, data: body }) }
+ })
+ })
+ req.on('error', reject)
+ if (opts.body) req.write(typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body))
+ req.end()
+ })
+}
+
+async function get3cxToken () {
+ if (pbxToken && Date.now() < pbxTokenExpiry - 60000) return pbxToken // 1min buffer
+ if (!PBX_USER || !PBX_PASS) return null
+
+ try {
+ const res = await httpsFetch(PBX_URL + '/webclient/api/Login/GetAccessToken', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ Username: PBX_USER, Password: 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
+}
+
+async function poll3cxCallLog () {
+ const token = await get3cxToken()
+ if (!token) return
+
+ try {
+ // Fetch last 20 calls from the last 5 minutes
+ const since = new Date(Date.now() - 5 * 60 * 1000).toISOString()
+ const url = `${PBX_URL}/xapi/v1/ReportCallLogData?$top=20&$orderby=StartTime%20desc&$filter=StartTime%20gt%20${encodeURIComponent(since)}`
+
+ const res = await httpsFetch(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
+
+ // Only process answered/completed calls (not segments still in progress)
+ if (!call.TalkingDuration && !call.Answered) continue
+
+ processedCallIds.add(callId)
+
+ // Parse call data
+ const direction = (call.Direction || '').toLowerCase().includes('inbound') ? 'in' : 'out'
+ const isOutbound = direction === 'out'
+ const sourcePhone = call.SourceCallerId || call.SourceDn || ''
+ const destPhone = call.DestinationCallerId || call.DestinationDn || ''
+ const remotePhone = isOutbound ? destPhone : sourcePhone
+ const ext = isOutbound ? call.SourceDn : call.DestinationDn
+
+ // Parse duration from ISO 8601 duration (PT1M30S) or seconds
+ let durationSec = 0
+ const td = call.TalkingDuration || ''
+ if (typeof td === 'string' && td.startsWith('PT')) {
+ const minMatch = td.match(/(\d+)M/)
+ const secMatch = td.match(/(\d+)S/)
+ const hrMatch = td.match(/(\d+)H/)
+ durationSec = (hrMatch ? parseInt(hrMatch[1]) * 3600 : 0) +
+ (minMatch ? parseInt(minMatch[1]) * 60 : 0) +
+ (secMatch ? parseInt(secMatch[1]) : 0)
+ } else if (typeof td === 'number') {
+ durationSec = td
+ }
+
+ // Skip very short/empty calls
+ if (durationSec < 3 && !call.Answered) continue
+
+ log(`3CX POLL: ${sourcePhone} → ${destPhone} dir=${direction} dur=${durationSec}s answered=${call.Answered} (${callId})`)
+
+ // Lookup customer and log
+ try {
+ const customer = remotePhone ? await lookupCustomerByPhone(remotePhone) : null
+ const customerName = customer ? customer.name : null
+ const customerLabel = customer ? customer.customer_name : 'Inconnu'
+
+ // Log Communication in ERPNext
+ if (customerName) {
+ const durationMin = Math.floor(durationSec / 60)
+ const durationS = durationSec % 60
+ const durationStr = `${durationMin}m${durationS.toString().padStart(2, '0')}s`
+
+ 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: ${durationStr}${call.Answered ? '' : ' (manque)'}`,
+ subject: `Appel ${isOutbound ? 'vers' : 'de'} ${remotePhone}`,
+ reference_doctype: 'Customer',
+ reference_name: customerName,
+ status: 'Linked',
+ })
+ log(`3CX logged to ERPNext: ${remotePhone} → ${customerName} (${durationStr})`)
+ }
+
+ // Broadcast SSE
+ 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 processed IDs (keep last 500)
+ if (processedCallIds.size > 500) {
+ const arr = [...processedCallIds]
+ arr.splice(0, arr.length - 500)
+ processedCallIds.clear()
+ arr.forEach(id => processedCallIds.add(id))
+ }
+ } catch (e) {
+ log('3CX poll error:', e.message)
+ }
+}
+
+function start3cxPoller () {
+ if (!PBX_ENABLED) {
+ log('3CX poller: DISABLED (PBX_ENABLED != 1, using Twilio instead)')
+ return
+ }
+ if (!PBX_USER || !PBX_PASS) {
+ log('3CX poller: DISABLED (no PBX_USER/PBX_PASS)')
+ return
+ }
+ log(`3CX poller: ENABLED (every ${PBX_POLL_INTERVAL / 1000}s) → ${PBX_URL}`)
+ // Initial poll after 5s
+ setTimeout(poll3cxCallLog, 5000)
+ pbxPollTimer = setInterval(poll3cxCallLog, PBX_POLL_INTERVAL)
+}
+
+server.listen(PORT, '0.0.0.0', () => {
+ log(`targo-hub listening on :${PORT}`)
+ log(` SSE: GET /sse?topics=customer:C-LPB4`)
+ log(` Broadcast: POST /broadcast`)
+ log(` SMS In: POST /webhook/twilio/sms-incoming`)
+ log(` SMS Out: POST /send/sms`)
+ log(` 3CX Call: POST /webhook/3cx/call-event`)
+ log(` Voice Tkn: GET /voice/token`)
+ log(` Voice TML: POST /voice/twiml`)
+ log(` Voice Sts: POST /voice/status`)
+ log(` Health: GET /health`)
+ log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`)
+ log(` Telephony: GET /telephony/overview`)
+ log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`)
+ // Start 3CX poller
+ start3cxPoller()
+})