← 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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, '<')}</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;