← back to Butlr

routes/live.js

550 lines

// /live route — live call ticker with INLINE listen (no leaving the page).
// On host = live.agentabrams.com, also serves at /.
// AJAX-refreshes the card list every 3s; audio plays via Web Audio API + WS.

const express = require('express');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const data = require('../lib/data');

const router = express.Router();

const REC_DIR = path.join(__dirname, '..', 'data', 'recordings');
const MIN_DONE_SEC = 5;  // hide sub-5s completed calls (no-answer / instant hangups)

// Recording duration in seconds, cached on the call row after the first probe
// so we don't shell out to ffprobe on every /api/done refresh.
function recordingDurationSec(call) {
  if (typeof call.recording_duration_sec === 'number') return call.recording_duration_sec;
  const f = path.join(REC_DIR, call.id + '.mp3');
  if (!fs.existsSync(f)) return 0;
  try {
    const out = execFileSync('ffprobe',
      ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f],
      { timeout: 4000 }).toString().trim();
    const d = Math.round((parseFloat(out) || 0) * 10) / 10;
    data.patchRow(call.id, { recording_duration_sec: d });
    return d;
  } catch { return 0; }
}

const LIVE_STATUSES = new Set(['queued', 'dialing', 'on_hold', 'connected', 'ringing']);
const LIVE_HOST = 'live.agentabrams.com';

// Max plausible age (since last activity) for each non-terminal status. A call
// that's been "dialing" for an hour is a zombie — its terminal Twilio callback
// (busy/no-answer/completed) never arrived, so the row never left the live set.
// Pre-pickup states resolve in well under a minute; an active call gets partial
// callbacks every few seconds, so a long-stale updated_at means it's dead.
const STALE_MS = {
  queued: 3 * 60_000, dialing: 3 * 60_000, ringing: 3 * 60_000,
  on_hold: 20 * 60_000, connected: 20 * 60_000,
};
function isStale(c) {
  const ms = STALE_MS[c.status];
  if (!ms) return false;
  const last = new Date(c.updated_at || c.created_at).getTime();
  if (!isFinite(last)) return false;
  return (Date.now() - last) > ms;
}

function escapeHtml(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}

function pickLive(calls) {
  return (calls || []).filter((c) => LIVE_STATUSES.has(c.status) && !isStale(c))
    .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
}

// ── Stale-call reaper ─────────────────────────────────────────────────
// Belt to the display filter's suspenders: actually mark zombie rows
// terminal so they leave the dataset (and the cost ledger isn't waiting on
// a callback that will never come). Runs at module load + every 60s.
function reapStaleCalls() {
  try {
    const all = data.listCallsUnscoped();
    let n = 0;
    for (const c of all) {
      if (LIVE_STATUSES.has(c.status) && isStale(c)) {
        data.patchRow(c.id, {
          status: 'failed',
          notes: ((c.notes || '') + ' [stale-swept: stuck in ' + c.status + ', no terminal Twilio callback]').trim(),
          stale_swept_at: new Date().toISOString(),
        });
        n++;
      }
    }
    if (n) console.log('[live-reaper] swept ' + n + ' stale call(s) → failed');
  } catch (e) { console.error('[live-reaper]', e.message); }
}
reapStaleCalls();
const _reaperTimer = setInterval(reapStaleCalls, 60_000);
if (_reaperTimer.unref) _reaperTimer.unref();

function fmtAgo(ts) {
  try {
    const ms = Date.now() - new Date(ts).getTime();
    if (ms < 60_000) return Math.floor(ms / 1000) + 's ago';
    if (ms < 3600_000) return Math.floor(ms / 60_000) + 'm ago';
    return Math.floor(ms / 3600_000) + 'h ago';
  } catch { return ts || ''; }
}

function cardHtml(c) {
  const id = escapeHtml(c.id);
  const phone = escapeHtml(c.business_phone || '');
  const name = escapeHtml(c.business_name || c.callback_name || '(unknown)');
  const status = escapeHtml(c.status);
  const goal = escapeHtml((c.goal || '').slice(0, 180));
  const ts = c.updated_at || c.created_at;
  const ago = escapeHtml(fmtAgo(ts));
  const sampleRate = c.vapi_call_id ? 16000 : 8000;
  return `<article class="card status-${status}" data-id="${id}" data-sr="${sampleRate}">
    <header>
      <div class="biz">${name}</div>
      <div class="meta">
        <span class="pill">${status.toUpperCase()}</span>
        <span>${phone}</span>
        <span>·</span>
        <span class="ago">${ago}</span>
      </div>
    </header>
    ${goal ? `<p class="goal">${goal}${(c.goal || '').length > 180 ? '…' : ''}</p>` : ''}
    <div class="actions">
      <button class="btn listen-btn" type="button" data-id="${id}" data-sr="${sampleRate}">🔊 Listen</button>
      <span class="ws-state muted" data-id="${id}"></span>
      <a class="btn ghost" href="/calls/${id}">Detail →</a>
    </div>
  </article>`;
}

function renderLive(calls) {
  const live = pickLive(calls);
  const cardsHtml = live.length
    ? live.map(cardHtml).join('\n')
    : '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <meta name="robots" content="noindex,nofollow" />
  <title>Live calls · Butlr</title>
  <style>
    :root { --ink:#101828; --muted:#64748b; --bg:#0f172a; --card:#1e293b; --line:#334155; --green:#22c55e; --amber:#f59e0b; --blue:#3b82f6; --pink:#ec4899; }
    * { box-sizing: border-box; }
    body { font-family: 'Inter',system-ui,-apple-system,sans-serif; background: var(--bg); color: #e2e8f0; margin: 0; min-height: 100vh; }
    header.top { padding: 24px 32px 16px; border-bottom: 1px solid var(--line); display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
    h1 { font-family: 'Playfair Display', Georgia, serif; font-weight: 500; margin: 0; font-size: 26px; color: #f8fafc; }
    .dot { width: 10px; height: 10px; border-radius:50%; background: var(--green); animation: pulse 1.4s infinite; }
    @keyframes pulse { 0%,100%{opacity:.4;transform:scale(1)} 50%{opacity:1;transform:scale(1.25)} }
    .sub { color: var(--muted); font-size: 13px; }
    main { max-width: 1100px; margin: 0 auto; padding: 24px 32px 80px; display: grid; grid-template-columns: repeat(auto-fill,minmax(340px,1fr)); gap: 14px; }
    .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
    .card.status-connected { border-left: 4px solid var(--green); }
    .card.status-on_hold { border-left: 4px solid var(--amber); }
    .card.status-dialing, .card.status-ringing { border-left: 4px solid var(--blue); }
    .card.status-queued { border-left: 4px solid var(--pink); }
    .biz { font-weight: 600; font-size: 15px; color: #f1f5f9; margin-bottom: 4px; }
    .meta { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size: 12px; color: #94a3b8; }
    .pill { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: var(--green); color: #022; font-weight: 700; letter-spacing: .5px; }
    .card.status-on_hold .pill { background: var(--amber); }
    .card.status-dialing .pill, .card.status-ringing .pill { background: var(--blue); color: #f8fafc; }
    .card.status-queued .pill { background: var(--pink); color: #fff; }
    .goal { color: #cbd5e1; font-size: 13px; line-height: 1.5; margin: 8px 0 10px; }
    .actions { display:flex; gap:8px; align-items:center; }
    .btn { display:inline-flex; align-items:center; gap:4px; padding: 6px 10px; background: var(--green); color: #022; text-decoration:none; border:0; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
    .btn.ghost { background:#334155; color:#f1f5f9; font-weight:500; }
    .btn:hover { filter: brightness(1.08); }
    .btn.listening { background: #ef4444; color: #fff; }
    .ws-state { font-size: 11px; color: var(--muted); }
    .ws-state.active { color: var(--green); }
    .ws-state.err { color: #ef4444; }
    .empty { grid-column: 1/-1; text-align:center; padding: 80px 20px; color: var(--muted); }
    .empty p { margin: 0 0 8px; }
    .count { color: var(--muted); font-size: 13px; }
  </style>
</head>
<body>
  <header class="top">
    <div class="dot"></div>
    <h1>Live calls</h1>
    <span class="count"><span id="liveCount">${live.length}</span> active</span>
    <span class="sub" style="margin-left:auto">auto-refresh 3s · click 🔊 Listen to hear live (no page reload)</span>
  </header>
  <div id="audio-unlock-banner" onclick="unlockAudio()" style="position:sticky;top:0;z-index:50;background:#22c55e;color:#022;text-align:center;padding:10px;font-weight:700;cursor:pointer;font-size:14px;">🔊 CLICK HERE ONCE TO ENABLE SOUND — chime on every new call + live audio plays automatically as calls connect</div>
  <div style="display:none">
  </div>
  <main id="grid">${cardsHtml}</main>

  <section id="completed-section" style="max-width:1100px;margin:0 auto;padding:8px 32px 80px;">
    <h2 style="font-family:'Playfair Display',Georgia,serif;font-weight:500;font-size:20px;color:#f8fafc;border-top:1px solid var(--line);padding-top:24px;margin:24px 0 4px;">Completed calls <span id="doneCount" style="font-size:13px;color:var(--muted);font-weight:400;"></span></h2>
    <p class="sub" style="margin:0 0 16px;">recent successful calls · play the recording inline</p>
    <div id="doneGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;"></div>
  </section>

<script>
(function(){
  // Map of callId → { ws, audioCtx, btn, stateEl }
  const sessions = new Map();

  // Call IDs we've already announced with a chime. Seeded on load from the
  // server-rendered cards so we DON'T beep for calls already on screen —
  // only for genuinely new calls that arrive after the page is open.
  const seenCallIds = new Set();
  document.querySelectorAll('#grid .card[data-id]').forEach(function(el){
    seenCallIds.add(el.dataset.id);
  });

  // Shared AudioContext — browsers require a user gesture to start audio.
  // We unlock ONE context on the first click anywhere, then reuse it for every
  // call so auto-play-on-connect actually produces sound.
  let sharedCtx = null;
  let audioUnlocked = false;
  function ensureCtx(sampleRate) {
    if (!sharedCtx) {
      sharedCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (sharedCtx.state === 'suspended') sharedCtx.resume();
    return sharedCtx;
  }
  function unlockAudio() {
    if (audioUnlocked) return;
    try {
      ensureCtx();
      // play a 1-sample silent buffer to fully unlock on iOS/Safari
      const b = sharedCtx.createBuffer(1, 1, 22050);
      const s = sharedCtx.createBufferSource();
      s.buffer = b; s.connect(sharedCtx.destination); s.start(0);
      audioUnlocked = true;
      const banner = document.getElementById('audio-unlock-banner');
      if (banner) { banner.style.display = 'none'; }
    } catch (e) {}
  }
  document.addEventListener('click', unlockAudio, { once: false });
  document.addEventListener('keydown', unlockAudio, { once: false });

  // Re-surface the unlock banner (loudly) when live audio is arriving but the
  // browser is still muted — the only thing standing between Steve and sound
  // is one click. Throttled so it doesn't thrash on every frame.
  let _bannerFlashTs = 0;
  function flashUnlockBanner() {
    const now = Date.now();
    if (now - _bannerFlashTs < 1000) return;
    _bannerFlashTs = now;
    const banner = document.getElementById('audio-unlock-banner');
    if (!banner) return;
    banner.style.display = 'block';
    banner.textContent = '🔇 LIVE AUDIO IS PLAYING BUT YOUR BROWSER IS MUTED — CLICK ANYWHERE TO HEAR IT';
    banner.style.background = '#ef4444';
    banner.style.color = '#fff';
    banner.style.animation = 'pulse 1s infinite';
  }

  // Two-tone ascending chime (no audio asset needed) — fired whenever a NEW
  // call appears in the grid so Steve hears "a call is being made" even before
  // the called party picks up (the live stream is silent until they speak).
  // Needs the shared AudioContext unlocked first (the green banner does that).
  function playChime() {
    if (!audioUnlocked) return;
    try {
      const ctx = ensureCtx();
      const t0 = ctx.currentTime + 0.01;
      [[880, t0, 0.12], [1318.5, t0 + 0.13, 0.18]].forEach(function(spec){
        const freq = spec[0], start = spec[1], dur = spec[2];
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.value = freq;
        gain.gain.setValueAtTime(0.0001, start);
        gain.gain.exponentialRampToValueAtTime(0.25, start + 0.02);
        gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
        osc.connect(gain); gain.connect(ctx.destination);
        osc.start(start); osc.stop(start + dur + 0.02);
      });
    } catch (e) {}
  }

  function getScheme() { return location.protocol === 'https:' ? 'wss:' : 'ws:'; }

  function startListen(callId, sampleRate, btn, stateEl) {
    let audioCtx, ws, nextPlayTime, frameCount = 0;
    try {
      audioCtx = ensureCtx(sampleRate);
      nextPlayTime = audioCtx.currentTime + 0.1;
    } catch (e) {
      stateEl.textContent = 'no audio support';
      stateEl.className = 'ws-state err';
      return;
    }

    const url = getScheme() + '//' + location.host + '/listen-ws/' + callId;
    ws = new WebSocket(url);
    ws.binaryType = 'arraybuffer';
    stateEl.textContent = 'connecting…';
    stateEl.className = 'ws-state';

    ws.onopen = () => {
      stateEl.textContent = 'connected · waiting for audio';
      stateEl.className = 'ws-state active';
      btn.textContent = '■ Stop';
      btn.classList.add('listening');
    };
    ws.onclose = () => {
      stateEl.textContent = '';
      btn.textContent = '🔊 Listen';
      btn.classList.remove('listening');
      sessions.delete(callId);
    };
    ws.onerror = () => {
      stateEl.textContent = 'error';
      stateEl.className = 'ws-state err';
    };
    ws.onmessage = (ev) => {
      // Audio is arriving. If the context is suspended (autoplay policy) or the
      // user never clicked to unlock, the buffers play SILENTLY — that's the
      // "I heard nothing" bug. Force a resume every frame, and if still locked,
      // surface the unlock banner loudly so one click turns sound on.
      if (audioCtx.state === 'suspended') { try { audioCtx.resume(); } catch (e) {} }
      if (!audioUnlocked || audioCtx.state === 'suspended') flashUnlockBanner();
      const pcm16 = new Int16Array(ev.data);
      const f32 = new Float32Array(pcm16.length);
      for (let i = 0; i < pcm16.length; i++) f32[i] = pcm16[i] / 32768;
      const buf = audioCtx.createBuffer(1, f32.length, sampleRate);
      buf.copyToChannel(f32, 0);
      const src = audioCtx.createBufferSource();
      src.buffer = buf;
      src.connect(audioCtx.destination);
      const startAt = Math.max(nextPlayTime, audioCtx.currentTime + 0.02);
      src.start(startAt);
      nextPlayTime = startAt + buf.duration;
      frameCount++;
      if (frameCount === 1 || frameCount % 20 === 0) {
        stateEl.textContent = '● LIVE · ' + frameCount + ' frames';
        stateEl.className = 'ws-state active';
      }
    };

    sessions.set(callId, { ws, audioCtx, btn, stateEl });
  }

  function stopListen(callId) {
    const s = sessions.get(callId);
    if (!s) return;
    try { s.ws && s.ws.close(); } catch(e){}
    /* keep shared ctx alive for reuse */
    sessions.delete(callId);
    s.btn.textContent = '🔊 Listen';
    s.btn.classList.remove('listening');
    s.stateEl.textContent = '';
    s.stateEl.className = 'ws-state';
  }

  function wireButtons() {
    document.querySelectorAll('.listen-btn').forEach((btn) => {
      if (btn._wired) return;
      btn._wired = true;
      btn.addEventListener('click', () => {
        const id = btn.dataset.id;
        const sr = parseInt(btn.dataset.sr, 10) || 8000;
        const stateEl = document.querySelector('.ws-state[data-id="' + id + '"]');
        if (sessions.has(id)) stopListen(id);
        else startListen(id, sr, btn, stateEl);
      });
    });
  }

  wireButtons();

  // AJAX-refresh card grid every 3s WITHOUT killing active audio.
  // We only re-render cards that aren't currently being listened to;
  // listening cards stay in place so their <button> + WS stay alive.
  async function refresh() {
    try {
      const r = await fetch('/api/live', { credentials: 'same-origin' });
      const j = await r.json();
      const calls = (j.calls || []);
      const liveIds = new Set(calls.map(c => c.id));
      document.getElementById('liveCount').textContent = calls.length;

      // Chime for any call we haven't announced yet — a call was just made.
      calls.forEach((c) => {
        if (!seenCallIds.has(c.id)) {
          seenCallIds.add(c.id);
          playChime();
        }
      });

      // Remove cards for calls that ended — unless we're listening to them (keep so user
      // can finish hearing the wind-down before WS closes from the server side).
      // Scope to #grid: the completed-calls history (#doneGrid) also uses .card,
      // and an unscoped selector would delete those done cards every refresh.
      document.querySelectorAll('#grid .card').forEach((el) => {
        const id = el.dataset.id;
        if (!liveIds.has(id) && !sessions.has(id)) el.remove();
      });

      // Re-render new + updated cards (skip ones we're listening to so the button keeps state).
      const grid = document.getElementById('grid');
      // Remove existing empty-state if any
      grid.querySelectorAll('.empty').forEach(e => e.remove());
      calls.forEach((c) => {
        if (sessions.has(c.id)) {
          // Just update the timestamp + status pill without replacing the card.
          const el = grid.querySelector('.card[data-id="' + c.id + '"]');
          if (el) {
            const pill = el.querySelector('.pill');
            if (pill) pill.textContent = c.status.toUpperCase();
            el.className = 'card status-' + c.status;
          }
          return;
        }
        const existing = grid.querySelector('.card[data-id="' + c.id + '"]');
        const html = (${cardHtml.toString()})(c);
        if (existing) existing.outerHTML = html;
        else grid.insertAdjacentHTML('beforeend', html);
        // AUTO-LISTEN — open the WS the instant the call appears (any live
        // status), not just on connect. The connected window can be short
        // (voicemail + AMD hangup), so opening early guarantees we catch the
        // audio the moment frames start. No frames flow until the stream
        // begins, so connecting during dialing is harmless.
        if (!sessions.has(c.id)) {
          const btn = grid.querySelector('.card[data-id="' + c.id + '"] .listen-btn');
          const stateEl = grid.querySelector('.card[data-id="' + c.id + '"] .ws-state');
          const sr = parseInt(btn && btn.dataset.sr, 10) || 8000;
          if (btn && stateEl) startListen(c.id, sr, btn, stateEl);
        }
      });
      if (calls.length === 0 && sessions.size === 0) {
        grid.innerHTML = '<div class="empty"><p>No live calls right now.</p><p class="sub">When a Butlr call is queued, dialing, on hold, or connected, it will appear here.</p></div>';
      }
      wireButtons();
    } catch (e) { /* swallow */ }
  }
  setInterval(refresh, 3000);

  // ── Completed-calls history ──────────────────────────────────────────
  // Fetches the last N status=done calls and renders them with an inline
  // <audio> player streaming from /api/calls/:id/recording (same-origin,
  // session-cookie auth — token stays out of the DOM). Refreshes every 30s
  // so a call that just finished slides into the history without a reload.
  function doneEsc(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
      return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
    });
  }
  function doneAgo(ts) {
    try {
      var ms = Date.now() - new Date(ts).getTime();
      if (ms < 60000) return Math.floor(ms/1000) + 's ago';
      if (ms < 3600000) return Math.floor(ms/60000) + 'm ago';
      if (ms < 86400000) return Math.floor(ms/3600000) + 'h ago';
      return Math.floor(ms/86400000) + 'd ago';
    } catch(e) { return ''; }
  }
  async function loadCompleted() {
    try {
      var r = await fetch('/api/done?limit=24', { credentials: 'same-origin' });
      var j = await r.json();
      var calls = (j.calls || []);
      var countEl = document.getElementById('doneCount');
      var grid = document.getElementById('doneGrid');
      if (!grid) return;
      if (countEl) countEl.textContent = calls.length ? '· ' + calls.length + ' shown' : '';
      if (!calls.length) {
        grid.innerHTML = '<div style="grid-column:1/-1;color:var(--muted);font-size:13px;padding:24px 0;">No completed calls yet.</div>';
        return;
      }
      grid.innerHTML = calls.map(function(c){
        var biz = doneEsc(c.business_name || 'Unknown');
        var phone = doneEsc(c.business_phone || '');
        var goal = doneEsc((c.goal || '').slice(0, 140));
        var ago = doneAgo(c.updated_at || c.created_at);
        var id = doneEsc(c.id);
        var ds = Math.round(c.recording_duration_sec || 0);
        var dur = ds > 0 ? Math.floor(ds / 60) + ':' + String(ds % 60).padStart(2, '0') : '';
        return '<div class="card status-done" style="border-left:4px solid var(--muted);">' +
          '<div class="biz">' + biz + '</div>' +
          '<div class="meta">' +
            '<span class="pill" style="background:#475569;color:#e2e8f0;">DONE</span>' +
            (phone ? '<span>' + phone + '</span>' : '') +
            (dur ? '<span style="color:var(--green);font-weight:600;">▶ ' + dur + '</span>' : '') +
            '<span style="margin-left:auto;">' + ago + '</span>' +
          '</div>' +
          (goal ? '<div class="goal">' + goal + (c.goal && c.goal.length > 140 ? '\\u2026' : '') + '</div>' : '') +
          '<audio controls preload="metadata" src="/api/calls/' + id + '/recording" ' +
            'style="width:100%;height:34px;margin-top:6px;" ' +
            'onerror="this.style.display=\\'none\\';this.insertAdjacentHTML(\\'afterend\\',\\'<span style=&quot;color:#64748b;font-size:11px&quot;>no recording archived</span>\\')"></audio>' +
          '<div style="margin-top:6px;">' +
            '<a href="https://butlr.agentabrams.com/calls/' + id + '" target="_blank" rel="noopener" ' +
            'style="color:var(--muted);font-size:11px;text-decoration:none;">call detail \\u2192</a>' +
          '</div>' +
          '</div>';
      }).join('');
    } catch (e) { /* swallow */ }
  }
  loadCompleted();
  setInterval(loadCompleted, 30000);
})();
</script>
</body>
</html>`;
}

router.get('/live', async (req, res) => {
  try {
    const calls = data.listCallsUnscoped();
    res.type('html').send(renderLive(calls));
  } catch (e) {
    res.status(500).send('error: ' + e.message);
  }
});

router.get('/api/done', async (req, res) => {
  try {
    const limit = Math.min(parseInt(req.query.limit, 10) || 30, 100);
    const all = data.listCallsUnscoped();
    const done = all.filter((c) => c.status === 'done')
      .sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
    // Only surface real conversations — recording over MIN_DONE_SEC. Hides
    // no-answers, instant hangups, and AMD-killed voicemails.
    const calls = [];
    for (const c of done) {
      const dur = recordingDurationSec(c);
      if (dur > MIN_DONE_SEC) { calls.push({ ...c, recording_duration_sec: dur }); }
      if (calls.length >= limit) break;
    }
    res.json({ ok: true, calls });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

router.get('/api/live', async (req, res) => {
  try {
    const calls = data.listCallsUnscoped();
    res.json({ ok: true, calls: pickLive(calls) });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Hostname-aware: live.agentabrams.com/ → /live page
router.get('/', async (req, res, next) => {
  if ((req.hostname || '').toLowerCase() === LIVE_HOST) {
    try {
      const calls = data.listCallsUnscoped();
      return res.type('html').send(renderLive(calls));
    } catch (e) {
      return res.status(500).send('error: ' + e.message);
    }
  }
  next();
});

module.exports = router;