← back to Butlr

routes/admin.js

438 lines

// Admin routes — owner-gated maintenance views.
//
//   GET /admin/orphan-recordings        — MP3s in data/recordings/ with no
//                                          matching call_id in calls.json.
//                                          Steve identifies them by listening,
//                                          then deletes or re-links.
//
// All routes here are hard-gated by requireOwner in server.js (mounted on
// /admin prefix). Plus we double-check req.user.role === 'admin' so a
// non-admin signed-in user can't peek at orphans (potential PII leak —
// the audio could contain any prior caller's voice).

const express = require('express');
const fs = require('fs');
const path = require('path');
const data = require('../lib/data');
const transcribe = require('../lib/transcribe');
const { scanOrphans } = require('../lib/orphan-recordings');
const { requireAdmin } = require('../lib/admin-gate');
const dncCheck = require('../lib/dnc-check');
const users = require('../lib/users');

const router = express.Router();

// ── /admin landing — 4-card nav ─────────────────────────────────────
router.get('/admin', requireAdmin, (req, res) => {
  const allCalls = data.listCallsUnscoped();
  const live = allCalls.filter(c => ['dialing','connected','on_hold'].includes(c.status));
  const usersList = users.readAll();
  res.render('admin/index', {
    title: 'Admin — Butlr',
    meta_desc: 'Butlr admin dashboard.',
    users_count: usersList.length,
    calls_count: allCalls.length,
    live_count: live.length,
    req,
  });
});

// ── /admin/calls — call history ─────────────────────────────────────
router.get('/admin/calls', requireAdmin, (req, res) => {
  const calls = data.listCallsUnscoped()
    .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
    .slice(0, 500);
  res.render('admin/calls', {
    title: 'Call history — Admin',
    meta_desc: 'All Butlr calls.',
    calls,
    req,
  });
});

// ── /admin/live — active calls ───────────────────────────────────────
router.get('/admin/live', requireAdmin, (req, res) => {
  const active = data.listCallsUnscoped()
    .filter(c => ['dialing','connected','on_hold'].includes(c.status))
    .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
  res.render('admin/live', {
    title: 'Live calls — Admin',
    meta_desc: 'Active Butlr calls right now.',
    active,
    req,
  });
});

// ── /admin/users — user CRUD ─────────────────────────────────────────
router.get('/admin/users', requireAdmin, (req, res) => {
  const allUsers = users.readAll();
  const allCalls = data.listCallsUnscoped();
  const callsPerUser = {};
  for (const c of allCalls) {
    const uid = c.user_id || 'legacy';
    callsPerUser[uid] = (callsPerUser[uid] || 0) + 1;
  }
  res.render('admin/users', {
    title: 'Users — Admin',
    meta_desc: 'Butlr users.',
    users: allUsers.map(u => users.publicView ? users.publicView(u) : u),
    calls_per_user: callsPerUser,
    req,
  });
});

router.post('/admin/users/add', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
  const { name, email, role } = req.body || {};
  if (!name || !email) return res.status(400).send('name + email required');
  try {
    if (users.create) {
      users.create({ name, email, role: role === 'admin' ? 'admin' : 'user' });
    } else if (users.addUser) {
      users.addUser({ name, email, role: role === 'admin' ? 'admin' : 'user' });
    }
  } catch (e) { console.error('[admin] user add:', e.message); }
  res.redirect('/admin/users');
});

router.post('/admin/users/:id/delete', requireAdmin, (req, res) => {
  if (req.user && req.user.id === req.params.id) return res.status(400).send("can't remove yourself");
  try {
    if (users.removeById) users.removeById(req.params.id);
    else if (users.deleteUser) users.deleteUser(req.params.id);
  } catch (e) { console.error('[admin] user delete:', e.message); }
  res.redirect('/admin/users');
});

// ── /admin/contacts — lazy-search proxy + dial ───────────────────────
router.get('/admin/contacts', requireAdmin, (req, res) => {
  res.render('admin/contacts', {
    title: 'Contacts — Admin',
    meta_desc: 'Search Mac contacts and dial via Butlr.',
    req,
  });
});

// Proxy contacts-service health + search to the Mac2-resident service.
// The service URL comes from env (MAC2_CONTACTS_URL) so the prod Kamatera
// box reaches it via Tailscale: http://100.65.187.120:9933
const MAC2_CONTACTS_URL = process.env.MAC2_CONTACTS_URL || 'http://100.65.187.120:9933';

router.get('/admin/api/contacts-health', requireAdmin, async (req, res) => {
  try {
    const r = await fetch(MAC2_CONTACTS_URL + '/health', { signal: AbortSignal.timeout(2000) });
    if (!r.ok) return res.json({ ok: false, error: 'service_http_' + r.status });
    const j = await r.json();
    res.json({ ok: true, count: j.count || 0 });
  } catch (e) {
    res.json({ ok: false, error: e.message });
  }
});

router.get('/admin/api/contacts-search', requireAdmin, async (req, res) => {
  const q = String(req.query.q || '').trim().slice(0, 60);
  if (!q || q.length < 2) return res.json({ ok: true, contacts: [] });
  try {
    const r = await fetch(MAC2_CONTACTS_URL + '/search?q=' + encodeURIComponent(q), { signal: AbortSignal.timeout(4000) });
    if (!r.ok) return res.status(502).json({ ok: false, error: 'service_http_' + r.status });
    const j = await r.json();
    res.json({ ok: true, contacts: j.contacts || [] });
  } catch (e) {
    res.status(502).json({ ok: false, error: e.message });
  }
});

// Click-to-call from contacts list → queues a call row.
router.post('/admin/api/dial', requireAdmin, express.json(), (req, res) => {
  const { name, number, goal } = req.body || {};
  if (!name || !number) return res.status(400).json({ ok: false, error: 'missing name or number' });
  const phone = String(number).startsWith('+') ? number : (String(number).replace(/\D/g, '').length === 10 ? '+1' + String(number).replace(/\D/g, '') : '+' + String(number).replace(/\D/g, ''));
  const body = {
    category: 'other',
    category_label: 'Admin dial',
    business_name: name,
    business_phone: phone,
    callback_phone: '+13107130489',
    callback_name: 'Steve Abrams',
    callback_email: 'steve@designerwallcoverings.com',
    goal: String(goal || `Brief test call to ${name}.`).slice(0, 1000),
    account_number: '', last4_ssn: '', billing_zip: '', date_of_birth: '', auth_password: '', best_time_window: '',
    max_hold_minutes: 5, max_spend_cents: 200,
    consent_recording: true, consent_terms: true,
    notify_sms: false, notify_email: false,
    notes: `Admin dial from contacts UI by ${req.user?.email || req.user?.id || '?'}`,
  };
  const r = data.addCall(body, req.user?.id || 'rcA9SnnO');
  if (!r.ok) return res.status(400).json({ ok: false, error: 'addCall failed', detail: r.errors });
  return res.json({ ok: true, call_id: r.call.id });
});

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

// ── /admin/status — live system snapshot ─────────────────────────────
// On-demand health check of butlr + wallco-ai (the two pm2 processes
// Steve monitors). Mirrors the launchd cron at
// scripts/status-snapshot.sh — same shape, but live per-request.
// Cron polls every 2min and persists; this is the "now" view.
router.get('/admin/status', requireAdmin, async (req, res) => {
  const [butlrHealth, wallcoHealth] = await Promise.all([
    fetch('https://butlr.agentabrams.com/healthz', { signal: AbortSignal.timeout(5000) })
      .then(r => r.status).catch(() => 0),
    fetch('https://wallco.ai/health', { signal: AbortSignal.timeout(5000) })
      .then(r => r.status).catch(() => 0),
  ]);

  // pm2 stats via local-host pm2 jlist (this server runs on prod next to wallco-ai's pm2)
  let pm2Stats = {};
  try {
    const { execSync } = require('child_process');
    const blob = execSync('pm2 jlist 2>/dev/null', { encoding: 'utf8', maxBuffer: 5_000_000 });
    const list = JSON.parse(blob);
    for (const name of ['butlr', 'wallco-ai']) {
      const p = list.find(x => x.name === name);
      if (!p) { pm2Stats[name] = null; continue; }
      const e = p.pm2_env || {};
      pm2Stats[name] = {
        pid: p.pid,
        uptime_s: Math.floor((Date.now() - (e.pm_uptime || 0)) / 1000),
        restarts: e.restart_time || 0,
        status: e.status || 'unknown',
        memory_mb: Math.round((p.monit && p.monit.memory || 0) / 1024 / 1024 * 10) / 10,
        cpu_pct: p.monit && p.monit.cpu || 0,
      };
    }
  } catch (e) {
    pm2Stats._error = e.message;
  }

  // Recent alerts from launchd cron (best-effort — only present if cron is on this host)
  let recentAlerts = [];
  try {
    const fs = require('fs');
    const path = require('path');
    const alertDir = path.join(process.env.HOME || '/root', 'status');
    if (fs.existsSync(alertDir)) {
      recentAlerts = fs.readdirSync(alertDir)
        .filter(f => f.startsWith('ALERT-'))
        .sort().reverse().slice(0, 5)
        .map(f => {
          const body = fs.readFileSync(path.join(alertDir, f), 'utf8').slice(0, 400);
          return { file: f, body };
        });
    }
  } catch {}

  const rowFor = (name, label, healthCode) => {
    const s = pm2Stats[name];
    const ok = healthCode >= 200 && healthCode < 300;
    const pm2Ok = s && s.status === 'online';
    const color = ok && pm2Ok ? '#047857' : '#b91c1c';
    return `<tr>
      <td><strong>${label}</strong></td>
      <td style="color:${color};font-weight:600">${healthCode || 'ERR'}</td>
      <td>${s ? s.uptime_s + 's' : '—'}</td>
      <td>${s ? s.restarts : '—'}</td>
      <td style="color:${pm2Ok ? '#047857' : '#b91c1c'}">${s ? s.status : 'not found'}</td>
      <td>${s ? s.memory_mb + ' MB' : '—'}</td>
      <td>${s ? s.cpu_pct + '%' : '—'}</td>
    </tr>`;
  };

  res.type('html').send(`<!doctype html><html><head>
    <meta charset="utf-8">
    <title>Status — Butlr admin</title>
    <meta http-equiv="refresh" content="30">
    <link rel="stylesheet" href="/css/site.css">
    <style>
      body { font-family: 'Inter', system-ui, sans-serif; max-width: 920px; margin: 0 auto; padding: 24px; }
      table { border-collapse: collapse; width: 100%; margin: 14px 0; background: #fff; }
      th, td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; font-size: 14px; }
      th { background: #f9fafb; font-weight: 600; }
      .muted { color: #6b7280; font-size: 12px; }
      .alert { background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 12px 14px; margin: 10px 0; }
      .alert pre { font-size: 11px; margin: 6px 0 0; white-space: pre-wrap; }
      .ok { background: #ecfdf5; border: 1px solid #047857; color: #047857; padding: 10px 14px; border-radius: 6px; font-size: 13px; }
    </style>
  </head><body>
    <h1>Status</h1>
    <p class="muted">Live snapshot · auto-refresh every 30s · ${new Date().toISOString().slice(0,19)}Z</p>

    <table>
      <thead><tr><th>Service</th><th>HTTP</th><th>Uptime</th><th>Restarts</th><th>pm2</th><th>Memory</th><th>CPU</th></tr></thead>
      <tbody>
        ${rowFor('butlr', 'butlr', butlrHealth)}
        ${rowFor('wallco-ai', 'wallco-ai', wallcoHealth)}
      </tbody>
    </table>

    <h2 style="font-size:18px;margin-top:24px">Recent alerts (last 5)</h2>
    ${recentAlerts.length === 0
      ? '<div class="ok">No alerts recorded.</div>'
      : recentAlerts.map(a => `<div class="alert"><strong>${a.file.replace('ALERT-','').replace('.txt','')}</strong><pre>${a.body.replace(/</g, '&lt;')}</pre></div>`).join('')}

    <p class="muted" style="margin-top:24px"><a href="/admin">← Admin dashboard</a></p>
  </body></html>`);
});

router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
  let orphans;
  try { orphans = scanOrphans(); }
  catch (e) { return res.status(500).send(`orphan scan error: ${e.message}`); }

  const rows = orphans.map(o => `
    <tr>
      <td><code>${o.id}</code></td>
      <td>${o.size_kb} KB</td>
      <td class="muted">${o.mtime} UTC</td>
      <td><audio controls preload="none" src="/admin/orphan-recordings/${o.id}.mp3" style="width:280px;height:32px"></audio></td>
    </tr>`).join('');

  res.type('html').send(`<!doctype html><html><head>
    <meta charset="utf-8">
    <title>Orphan recordings — Butlr admin</title>
    <link rel="stylesheet" href="/css/site.css">
    <style>
      table { border-collapse: collapse; width: 100%; margin-top: 16px; }
      td, th { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; vertical-align: middle; text-align: left; font-size: 14px; }
      .muted { color: #6b7280; font-size: 12px; }
    </style>
  </head><body>
    <main style="max-width: 920px; margin: 0 auto; padding: 24px;">
      <h1>Orphan recordings</h1>
      <p class="muted">MP3s in <code>data/recordings/</code> with no matching call in <code>data/calls.json</code>. Listen to identify, then delete or re-link manually.</p>
      ${orphans.length === 0
        ? '<p>No orphans. Every MP3 maps to a known call.</p>'
        : `<table>
            <thead><tr><th>ID</th><th>Size</th><th>Modified</th><th>Audio</th></tr></thead>
            <tbody>${rows}</tbody>
          </table>`}
      <p class="muted" style="margin-top:24px;">
        Total orphans: ${orphans.length}.
        <a href="/calls">← Your calls</a>
      </p>
    </main>
  </body></html>`);
});

// ── DNC suppression list management ─────────────────────────────────
// View the internal suppression list, add a number manually, remove
// a number. Audit trail is implicit via reason/source/added_at fields.
router.get('/admin/dnc', requireAdmin, (req, res) => {
  let list = [];
  try { list = JSON.parse(fs.readFileSync(dncCheck.INTERNAL_SUPPRESSION, 'utf8')); }
  catch {}
  let federalSize = 0, federalOk = true, federalPath = dncCheck.DNC_FILE;
  try {
    const stat = fs.statSync(federalPath);
    federalSize = stat.size;
  } catch { federalOk = false; }

  const rows = (Array.isArray(list) ? list : []).map(e => `
    <tr>
      <td><code>${escapeHtml(e.phone)}</code></td>
      <td>${escapeHtml(e.reason || '—')}</td>
      <td>${escapeHtml(e.source || '—')}</td>
      <td class="muted">${escapeHtml(String(e.added_at || '').slice(0, 19))}</td>
      <td><form method="POST" action="/admin/dnc/remove" style="margin:0"><input type="hidden" name="phone" value="${escapeHtml(e.phone)}"><button type="submit" style="font-size:11px; padding:4px 10px; cursor:pointer">remove</button></form></td>
    </tr>`).join('');

  res.type('html').send(`<!doctype html><html><head>
    <meta charset="utf-8">
    <title>DNC suppression — Butlr admin</title>
    <link rel="stylesheet" href="/css/site.css">
    <style>
      table { border-collapse: collapse; width: 100%; margin-top: 12px; }
      td, th { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; font-size: 14px; vertical-align: middle; }
      .muted { color: #6b7280; font-size: 12px; }
      .card { padding: 14px 18px; border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; background: #fff; }
      .pill { display:inline-block; padding:2px 8px; border-radius:9999px; font-size:11px; }
      .pill.ok { background: #ecfdf5; color: #047857; }
      .pill.warn { background: #fef3c7; color: #92400e; }
    </style>
  </head><body>
    <main style="max-width: 920px; margin: 0 auto; padding: 24px;">
      <h1>DNC suppression</h1>
      <p class="muted">Internal do-not-call list. Numbers here are blocked at the gate before any Twilio/Vapi dispatch. Audit trail in <code>data/dnc-blocks.jsonl</code>.</p>

      <div class="card">
        <strong>Federal DNC snapshot</strong>
        <span class="pill ${federalOk ? (federalSize > 1000 ? 'ok' : 'warn') : 'warn'}">${federalOk ? `${(federalSize / 1024).toFixed(1)} KB on disk` : 'MISSING — gate fails closed'}</span>
        <p class="muted" style="margin:.5rem 0 0">Path: <code>${escapeHtml(federalPath)}</code>${federalSize < 1000 && federalOk ? ' · placeholder only — subscribe at https://telemarketing.donotcall.gov/ for the real registry' : ''}</p>
      </div>

      <div class="card">
        <strong>Add to internal suppression</strong>
        <form method="POST" action="/admin/dnc/add" style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:end; margin-top:8px">
          <label style="display:flex; flex-direction:column; gap:4px; font-size:12px; flex:1; min-width: 180px;">
            <span class="muted">Phone (E.164)</span>
            <input name="phone" required placeholder="+13105551212" pattern="\\+?[1-9][0-9]{1,14}" style="padding:.5rem .65rem; border:1px solid #e5e7eb; border-radius:6px">
          </label>
          <label style="display:flex; flex-direction:column; gap:4px; font-size:12px; flex:1.5; min-width: 180px;">
            <span class="muted">Reason</span>
            <input name="reason" required placeholder="user opted out / stop keyword / manual" style="padding:.5rem .65rem; border:1px solid #e5e7eb; border-radius:6px">
          </label>
          <button type="submit" style="padding:.5rem 1rem; cursor:pointer">+ Add</button>
        </form>
      </div>

      <h2 style="font-size:18px; margin-top:24px;">Current suppression list (${list.length})</h2>
      ${list.length === 0
        ? '<p class="muted">Empty. Numbers will be added here when (a) you submit the form above, or (b) someone replies STOP to an SMS, or (c) the SMS path adds them programmatically.</p>'
        : `<table>
            <thead><tr><th>Phone</th><th>Reason</th><th>Source</th><th>Added</th><th></th></tr></thead>
            <tbody>${rows}</tbody>
          </table>`}

      <p class="muted" style="margin-top:24px;"><a href="/calls">← Your calls</a></p>
    </main>
  </body></html>`);
});

router.post('/admin/dnc/add', requireAdmin, (req, res) => {
  const phone = String((req.body && req.body.phone) || '').trim();
  const reason = String((req.body && req.body.reason) || 'manual').trim();
  if (!phone) return res.redirect('/admin/dnc');
  try {
    const r = dncCheck.addToInternalSuppression(phone, reason, 'admin_ui');
    if (!r.ok) console.warn('[admin/dnc] add failed:', r.error);
  } catch (e) { console.error('[admin/dnc] add exception:', e.message); }
  res.redirect('/admin/dnc');
});

router.post('/admin/dnc/remove', requireAdmin, (req, res) => {
  const phone = String((req.body && req.body.phone) || '').trim();
  if (!phone) return res.redirect('/admin/dnc');
  const target = dncCheck.e164Digits(phone);
  try {
    let list = [];
    try { list = JSON.parse(fs.readFileSync(dncCheck.INTERNAL_SUPPRESSION, 'utf8')); } catch {}
    if (!Array.isArray(list)) list = [];
    const before = list.length;
    list = list.filter(e => dncCheck.e164Digits(e.phone) !== target);
    if (list.length !== before) {
      const tmp = dncCheck.INTERNAL_SUPPRESSION + '.tmp';
      fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
      fs.renameSync(tmp, dncCheck.INTERNAL_SUPPRESSION);
    }
  } catch (e) { console.error('[admin/dnc] remove exception:', e.message); }
  res.redirect('/admin/dnc');
});

// Serve the orphan MP3 directly (admin-gated, no calls.json lookup).
router.get('/admin/orphan-recordings/:filename', requireAdmin, (req, res) => {
  const fname = req.params.filename;
  if (!/^[A-Za-z0-9_-]{6,16}\.mp3$/.test(fname)) return res.status(404).end();
  const fp = path.join(transcribe.RECORDINGS_DIR, fname);
  if (!fs.existsSync(fp)) return res.status(404).end();
  // Only serve if it's actually an orphan — otherwise force user through /api/calls
  const base = fname.replace(/\.mp3$/, '');
  const calls = data.listCallsUnscoped();
  if (calls.some(c => c.id === base)) return res.status(404).end();
  res.type('audio/mpeg');
  res.setHeader('Content-Disposition', `inline; filename="${fname}"`);
  fs.createReadStream(fp).pipe(res);
});

module.exports = router;