← back to Butlr
admin/dnc + dnc_flagged surface on /calls
2a886423ec9d52d5b9db30aa546cd5e30df56535 · 2026-05-13 09:34:03 -0700 · SteveStudio2
Two visual halves of the DNC compliance work shipped in the prior commit:
- /admin/dnc — owner+role=admin gated page:
* federal snapshot status (size on disk, MISSING warning if absent)
* add-to-suppression form (phone + reason, manual entry)
* current suppression table with per-row "remove" button
* POST /admin/dnc/add and /admin/dnc/remove are also admin-gated;
/remove uses atomic write (tmp+rename) like the main lib does
- /calls rows now render:
* 🚫 DNC pill next to the status pill (red, hover-tooltip with
reason+source)
* Red sub-line explaining what was blocked + link to /admin/dnc
* Only renders when row has dnc_flagged=true (set by tick() at the
pre-flight gate)
Together this gives Steve a complete loop: dial → DNC gate blocks →
row is flagged with explanation in his UI → he can remove from
suppression in admin if false-positive, or leave it as a permanent
block.
Files touched
M routes/admin.jsM views/public/calls.ejs
Diff
commit 2a886423ec9d52d5b9db30aa546cd5e30df56535
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 09:34:03 2026 -0700
admin/dnc + dnc_flagged surface on /calls
Two visual halves of the DNC compliance work shipped in the prior commit:
- /admin/dnc — owner+role=admin gated page:
* federal snapshot status (size on disk, MISSING warning if absent)
* add-to-suppression form (phone + reason, manual entry)
* current suppression table with per-row "remove" button
* POST /admin/dnc/add and /admin/dnc/remove are also admin-gated;
/remove uses atomic write (tmp+rename) like the main lib does
- /calls rows now render:
* 🚫 DNC pill next to the status pill (red, hover-tooltip with
reason+source)
* Red sub-line explaining what was blocked + link to /admin/dnc
* Only renders when row has dnc_flagged=true (set by tick() at the
pre-flight gate)
Together this gives Steve a complete loop: dial → DNC gate blocks →
row is flagged with explanation in his UI → he can remove from
suppression in admin if false-positive, or leave it as a permanent
block.
---
routes/admin.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++++
views/public/calls.ejs | 8 ++++
2 files changed, 117 insertions(+)
diff --git a/routes/admin.js b/routes/admin.js
index 937702b..9c90973 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -17,9 +17,14 @@ 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 router = express.Router();
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
+}
+
router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
let orphans;
try { orphans = scanOrphans(); }
@@ -60,6 +65,110 @@ router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
</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;
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index fefc610..5cabeb7 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -45,6 +45,9 @@
<div class="call-row-head">
<strong><%= c.business_name %></strong>
<span class="status status-<%= c.status %>" data-status-pill><%= c.status %></span>
+ <% if (c.dnc_flagged) { %>
+ <span class="dnc-pill" title="Blocked by DNC gate: <%= c.dnc_reason || '?' %> (<%= c.dnc_source || '?' %>)" style="display:inline-block;padding:2px 8px;border-radius:9999px;background:#fee2e2;color:#991b1b;font-size:11px;font-weight:600;letter-spacing:0.03em;margin-left:6px;">🚫 DNC</span>
+ <% } %>
</div>
<p class="muted small">
<%= c.category_label %> ·
@@ -52,6 +55,11 @@
queued <%= c.created_at.slice(0,16).replace('T',' ') %>
</p>
<p class="call-row-goal"><%= c.goal.length > 140 ? c.goal.slice(0,140)+'…' : c.goal %></p>
+ <% if (c.dnc_flagged) { %>
+ <p class="muted small" style="margin-top:.4rem;color:#991b1b;">
+ 🚫 Blocked: <%= c.dnc_field || 'phone' %> on <%= c.dnc_source || 'unknown' %> list — never dispatched to Twilio/Vapi. To allow this number, remove from <a href="/admin/dnc">/admin/dnc</a>.
+ </p>
+ <% } %>
</a>
<!-- IN-FLIGHT: listen-live link (hidden unless status is active) -->
← 9a769b7 gitignore: also untrack dnc-suppression + users.json + dnc-b
·
back to Butlr
·
dnc: pre-check endpoint + /calls quick-dial inline warning 73c5692 →