/** * Recent calls from the FreePBX CDR (MariaDB `asteriskcdrdb.cdr` on the PBX, * read-only user `logship_cdr`, runtimeConfig.pbxCdr). * * FreePBX writes MANY cdr rows per call (every Local/queue/follow-me leg), so * rows are grouped by `linkedid` (shared by all legs of one call) and reduced * to ONE entry per call: * inbound = a leg whose `channel` is a trunk channel (PJSIP/-…) * outbound = a leg whose `dstchannel` is a trunk channel * answered = an ANSWERED leg with billsec > 0 on the far side (for inbound: * a Dial leg to a non-trunk dstchannel — voicemail/announcement * legs don't count; for outbound: the trunk leg itself) * viaQueue = any leg has lastapp 'Queue' (app_queue) — the support line * The dashboard card shows: missed direct-dial inbound (needs a callback), * ALL Queue-routed inbound (answered or not — it's the main support line, * worth seeing either way), and successful outbound. */ import mysql from 'mysql2/promise' export interface RecentCall { linkedid: string direction: 'in' | 'out' answered: boolean viaQueue: boolean // any leg routed through a FreePBX Queue (app_queue) — the support line at: string // ISO of the earliest leg number: string // caller (in) / dialed (out) name: string // caller-id name (in) / dial label (out) extension: string // our side (agent extension) when known did: string duration: number // talk seconds (billsec of the far leg) legs: number } const g = globalThis as any let pool: mysql.Pool | null = g.__pbxCdrPool ?? null const cache: { rows: RecentCall[]; expires: number } = g.__pbxCdrCache ?? (g.__pbxCdrCache = { rows: [], expires: 0 }) export const getPool = (): mysql.Pool | null => { if (pool) return pool const cfg: any = (useRuntimeConfig() as any).pbxCdr || {} if (!cfg.host || !cfg.user || !cfg.password) return null pool = mysql.createPool({ host: cfg.host, port: Number(cfg.port || 3306), user: cfg.user, password: cfg.password, database: cfg.database || 'asteriskcdrdb', connectionLimit: 2, connectTimeout: 5000, waitForConnections: true, timezone: 'Z' }) g.__pbxCdrPool = pool return pool } const trunkPatterns = (): string[] => { const cfg: any = (useRuntimeConfig() as any).pbxCdr || {} return String(cfg.trunkPatterns || 'easybell_trunk').split(',').map((s: string) => s.trim()).filter(Boolean) } const isTrunk = (chan: string, pats: string[]) => { const c = String(chan || ''); return pats.some((p) => c.includes(p)) } export const extFromChannel = (chan: string): string => { const m = /^(?:PJSIP|SIP)\/(\d{2,6})-/.exec(String(chan || '')) || /^Local\/(\d{2,6})@/.exec(String(chan || '')) return m?.[1] || '' } // Caller-id NAME without our own decorations: the click-to-dial "Anruf: …" // prefix, the inbound-route "LogYou: …" prefix, and names that only repeat // the number (FreePBX fills the name with the number when CNAM is empty). const cleanName = (clid: string, number = ''): string => { const m = /^"([^"]*)"/.exec(String(clid || '')) let n = (m?.[1] || '').trim() n = n.replace(/^(Anruf|LogYou|CID)\s*:\s*/i, '').trim() if (!n || /^[\d\s+()\-]+$/.test(n) || n.replace(/\D/g, '') === String(number || '').replace(/\D/g, '')) return '' return n } export const classifyCalls = (rows: any[], pats: string[]): RecentCall[] => { const groups = new Map() for (const r of rows) { const key = String(r.linkedid || r.uniqueid) if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(r) } const out: RecentCall[] = [] for (const [linkedid, legs] of groups) { legs.sort((a, b) => new Date(a.calldate).getTime() - new Date(b.calldate).getTime()) const inboundLeg = legs.find((l) => isTrunk(l.channel, pats)) const outboundLegs = legs.filter((l) => isTrunk(l.dstchannel, pats)) const at = new Date(legs[0].calldate).toISOString() if (inboundLeg && outboundLegs.length === 0) { const answeredLeg = legs.find((l) => String(l.disposition) === 'ANSWERED' && Number(l.billsec) > 0 && String(l.lastapp) === 'Dial' && l.dstchannel && !isTrunk(l.dstchannel, pats)) out.push({ linkedid, direction: 'in', answered: !!answeredLeg, viaQueue: legs.some((l) => String(l.lastapp) === 'Queue'), at, number: String(inboundLeg.src || '').trim(), name: cleanName(inboundLeg.clid, inboundLeg.src), extension: answeredLeg ? extFromChannel(answeredLeg.dstchannel) : '', did: String(inboundLeg.did || inboundLeg.dst || ''), duration: answeredLeg ? Number(answeredLeg.billsec) : 0, legs: legs.length }) continue } if (outboundLegs.length) { const answeredLeg = outboundLegs.find((l) => String(l.disposition) === 'ANSWERED' && Number(l.billsec) > 0) const last = answeredLeg || outboundLegs[outboundLegs.length - 1] const agentLeg = legs.find((l) => extFromChannel(l.channel) && !isTrunk(l.channel, pats)) out.push({ linkedid, direction: 'out', answered: !!answeredLeg, viaQueue: false, at, number: String(last.dst || '').trim(), name: cleanName(legs.find((l) => /Anruf:/i.test(String(l.clid || '')))?.clid || '', last.dst), extension: extFromChannel(agentLeg?.channel || '') || extFromChannel(legs.find((l) => /^Local\/\d+@/.test(String(l.channel)))?.channel || ''), did: '', duration: answeredLeg ? Number(answeredLeg.billsec) : 0, legs: legs.length }) } } out.sort((a, b) => b.at.localeCompare(a.at)) return out } const loadShown = async (): Promise => { if (cache.expires > Date.now()) return cache.rows const p = getPool() if (!p) return null const [rows] = await p.query( 'SELECT calldate, clid, src, dst, dcontext, channel, dstchannel, lastapp, disposition, duration, billsec, uniqueid, linkedid, did FROM cdr WHERE calldate > NOW() - INTERVAL 30 DAY ORDER BY calldate DESC LIMIT 1500' ) as any const all = classifyCalls(rows as any[], trunkPatterns()) // Direct-dial inbound: missed only (an answered one was picked up personally, // nothing to follow up on). Queue-routed inbound (the support line): always // shown, answered or not — worth seeing regardless of outcome. const shown = all.filter((c) => (c.direction === 'in' && (!c.answered || c.viaQueue)) || (c.direction === 'out' && c.answered)) cache.rows = shown.slice(0, 150) cache.expires = Date.now() + 30 * 1000 return cache.rows } /** * Missed direct-dial inbound + all Queue-routed inbound + answered outbound, * newest first, one row per call. Cached 30 s per process (last 150 calls * over the 30-day CDR window). * `direction` narrows to 'in' | 'out' (default: both); `search` matches the * number (digits only, so formatting/spacing in the query doesn't matter) or * the caller-id name; `offset`/`limit` page through the filtered result. * `total` is the count after filtering (for page count); `missedTotal` is the * missed-inbound count within the search filter but ACROSS both directions * (independent of the `direction` tab), so the header badge stays accurate * no matter which tab is selected. */ export const recentCalls = async ( limit = 20, offset = 0, search = '', direction: 'all' | 'in' | 'out' = 'all' ): Promise<{ available: boolean; rows: RecentCall[]; total: number; missedTotal: number }> => { const shown = await loadShown() if (shown === null) return { available: false, rows: [], total: 0, missedTotal: 0 } const q = String(search || '').trim() const qDigits = q.replace(/\D+/g, '') const bySearch = shown.filter((c) => !q || (qDigits && String(c.number || '').replace(/\D+/g, '').includes(qDigits)) || String(c.name || '').toLowerCase().includes(q.toLowerCase()) ) const filtered = bySearch.filter((c) => direction === 'all' || c.direction === direction) const missedTotal = bySearch.filter((c) => c.direction === 'in' && !c.answered).length return { available: true, rows: filtered.slice(offset, offset + limit), total: filtered.length, missedTotal } }