[object Object]

← back to Butlr

dnc: pre-check endpoint + /calls quick-dial inline warning

73c5692d999e5355bd72e72840b28775f46d5d28 · 2026-05-13 10:06:58 -0700 · SteveStudio2

- GET /api/dnc-check?phone=… returns {allowed, reason, dry_run_bypass}.
  Auth-gated. Doesn't log a block — checks-only, no dial attempt
  happened. Gate-error (snapshot missing) surfaces as allowed=false
  with reason=gate_error + code, so UI can render the right message.
- /calls quick-dial form: phone-blur hits /api/dnc-check. On block,
  dial button is disabled + red 🚫 explainer ("on federal DNC" /
  "in internal suppression" / "DNC list unavailable"). On allowed,
  blue ✓ confirmation.

User now gets pre-flight feedback before pressing Dial; they don't
waste a submit cycle finding out the number is suppressed.

Files touched

Diff

commit 73c5692d999e5355bd72e72840b28775f46d5d28
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 10:06:58 2026 -0700

    dnc: pre-check endpoint + /calls quick-dial inline warning
    
    - GET /api/dnc-check?phone=… returns {allowed, reason, dry_run_bypass}.
      Auth-gated. Doesn't log a block — checks-only, no dial attempt
      happened. Gate-error (snapshot missing) surfaces as allowed=false
      with reason=gate_error + code, so UI can render the right message.
    - /calls quick-dial form: phone-blur hits /api/dnc-check. On block,
      dial button is disabled + red 🚫 explainer ("on federal DNC" /
      "in internal suppression" / "DNC list unavailable"). On allowed,
      blue ✓ confirmation.
    
    User now gets pre-flight feedback before pressing Dial; they don't
    waste a submit cycle finding out the number is suppressed.
---
 mac2-contacts-service/server.js | 186 ++++++++++++++++++++++++++++++++++++++++
 routes/admin.js                 | 145 +++++++++++++++++++++++++++++++
 routes/public.js                |  25 ++++++
 views/admin/calls.ejs           |  67 +++++++++++++++
 views/admin/contacts.ejs        | 110 ++++++++++++++++++++++++
 views/admin/index.ejs           |  55 ++++++++++++
 views/admin/live.ejs            |  42 +++++++++
 views/admin/users.ejs           |  71 +++++++++++++++
 views/public/calls.ejs          |  31 ++++++-
 9 files changed, 731 insertions(+), 1 deletion(-)

diff --git a/mac2-contacts-service/server.js b/mac2-contacts-service/server.js
new file mode 100644
index 0000000..546f37c
--- /dev/null
+++ b/mac2-contacts-service/server.js
@@ -0,0 +1,186 @@
+// Mac2-resident contacts service. Reads macOS Contacts via the SQLite DBs
+// at ~/Library/Application Support/AddressBook/Sources/*/AddressBook-v22.abcddb
+// — bypasses macOS TCC privacy prompts (which gate the AppleScript / Contacts
+// API). Direct file reads work because the privacy gate is on the API, not
+// the file itself.
+//
+// Listens on Tailscale + localhost so Butlr prod (Kamatera) can search
+// contacts without bulk-exporting anything. Per /search request returns
+// only matching name+phone, max 25 rows. Every read is audited.
+//
+// Endpoints:
+//   GET /health  → { ok, count, sources }
+//   GET /search?q=<query>  → { ok, contacts: [{ name, phones:[E.164] }] }
+//
+// Auth: X-Butlr-Token header == CONTACTS_SHARED_TOKEN.
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execFileSync } = require('child_process');
+
+const PORT = parseInt(process.env.CONTACTS_PORT || '9937', 10);
+const BIND = process.env.CONTACTS_BIND || '0.0.0.0';
+const SHARED_TOKEN = process.env.CONTACTS_SHARED_TOKEN || 'CHANGE_ME_BEFORE_USE';
+const LOG_PATH = path.join(os.homedir(), 'Library/Logs/butlr-contacts-access.log');
+
+const AB_SOURCES_DIR = path.join(os.homedir(), 'Library/Application Support/AddressBook/Sources');
+const OVERLAY_PATH = path.join(os.homedir(), '.butlr-contacts.json');
+
+function audit(...args) {
+  const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`;
+  try { fs.appendFileSync(LOG_PATH, line); } catch {}
+  process.stdout.write(line);
+}
+
+function findSourceDbs() {
+  try {
+    return fs.readdirSync(AB_SOURCES_DIR)
+      .map(s => path.join(AB_SOURCES_DIR, s, 'AddressBook-v22.abcddb'))
+      .filter(p => fs.existsSync(p));
+  } catch { return []; }
+}
+
+function normalize(rawPhone) {
+  const digits = String(rawPhone || '').replace(/\D/g, '');
+  if (!digits) return null;
+  if (digits.length === 10) return '+1' + digits;
+  if (digits.length === 11 && digits[0] === '1') return '+' + digits;
+  if (digits.length >= 8 && digits.length <= 15) return '+' + digits;
+  return null;
+}
+
+const SOURCES = findSourceDbs();
+audit(`found ${SOURCES.length} contacts DBs`);
+
+let CACHE = null;
+let CACHE_AT = 0;
+const CACHE_TTL = 5 * 60 * 1000;
+
+function loadAll() {
+  const t0 = Date.now();
+  const all = new Map(); // dedupe key = name|firstPhone
+  for (const db of SOURCES) {
+    try {
+      const sql = `SELECT IFNULL(r.ZFIRSTNAME,'') AS fn, IFNULL(r.ZLASTNAME,'') AS ln, IFNULL(r.ZORGANIZATION,'') AS org, p.ZFULLNUMBER
+                   FROM ZABCDRECORD r JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK
+                   WHERE p.ZFULLNUMBER IS NOT NULL`;
+      const out = execFileSync('/usr/bin/sqlite3', ['-separator', '\t', db, sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+      for (const line of out.split('\n')) {
+        if (!line.trim()) continue;
+        const [fn, ln, org, phone] = line.split('\t');
+        const name = ([fn, ln].filter(Boolean).join(' ').trim()) || org || '';
+        if (!name) continue;
+        const norm = normalize(phone);
+        if (!norm) continue;
+        const key = name.toLowerCase();
+        if (!all.has(key)) all.set(key, { name, phones: new Set() });
+        all.get(key).phones.add(norm);
+      }
+    } catch (e) {
+      audit(`source read ERROR ${path.basename(path.dirname(db))}: ${e.message}`);
+    }
+  }
+  // Merge in Butlr-overlay file (~/.butlr-contacts.json) — contacts you add
+  // via the Butlr admin UI that aren't in macOS Contacts. Overlay entries
+  // shadow same-name entries from SQLite, so you can correct / extend.
+  let overlayCount = 0;
+  try {
+    if (fs.existsSync(OVERLAY_PATH)) {
+      const overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8'));
+      if (Array.isArray(overlay)) {
+        for (const o of overlay) {
+          if (!o || !o.name) continue;
+          const key = String(o.name).toLowerCase();
+          const phones = (Array.isArray(o.phones) ? o.phones : [o.phone]).map(normalize).filter(Boolean);
+          if (!phones.length) continue;
+          const existing = all.get(key);
+          if (existing) {
+            for (const p of phones) existing.phones.add(p);
+          } else {
+            all.set(key, { name: String(o.name), phones: new Set(phones) });
+          }
+          overlayCount++;
+        }
+      }
+    }
+  } catch (e) {
+    audit(`overlay read ERROR: ${e.message}`);
+  }
+  const list = Array.from(all.values()).map(c => ({ name: c.name, phones: Array.from(c.phones) }));
+  audit(`loaded ${list.length} contacts in ${Date.now() - t0}ms (${SOURCES.length} SQLite sources + ${overlayCount} overlay entries)`);
+  CACHE = list;
+  CACHE_AT = Date.now();
+  return list;
+}
+
+// Add a contact to the overlay file. Idempotent — merges by lowercase name.
+function addOverlayContact(name, phone) {
+  const norm = normalize(phone);
+  if (!name || !norm) return { ok: false, error: 'name + valid phone required' };
+  let overlay = [];
+  try { if (fs.existsSync(OVERLAY_PATH)) overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8')) || []; } catch {}
+  const idx = overlay.findIndex(o => String(o.name).toLowerCase() === name.toLowerCase());
+  if (idx >= 0) {
+    if (!Array.isArray(overlay[idx].phones)) overlay[idx].phones = overlay[idx].phone ? [overlay[idx].phone] : [];
+    if (!overlay[idx].phones.includes(norm)) overlay[idx].phones.push(norm);
+  } else {
+    overlay.push({ name: String(name), phones: [norm] });
+  }
+  fs.writeFileSync(OVERLAY_PATH, JSON.stringify(overlay, null, 2));
+  // Invalidate cache so next read picks up the new entry
+  CACHE = null;
+  CACHE_AT = 0;
+  audit(`overlay ADD name="${name}" phone="${norm}"`);
+  return { ok: true, name, phone: norm };
+}
+
+function getContacts() {
+  if (!CACHE || (Date.now() - CACHE_AT) > CACHE_TTL) return loadAll();
+  return CACHE;
+}
+
+const app = express();
+
+app.use((req, res, next) => {
+  const token = req.headers['x-butlr-token'] || req.query.t;
+  if (token !== SHARED_TOKEN) {
+    audit(`AUTH FAIL ${req.ip} ${req.method} ${req.url}`);
+    return res.status(403).json({ ok: false, error: 'bad_token' });
+  }
+  next();
+});
+
+app.get('/health', (req, res) => {
+  try {
+    const c = getContacts();
+    res.json({ ok: true, count: c.length, sources: SOURCES.length, cached_age_ms: Date.now() - CACHE_AT });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+app.get('/search', (req, res) => {
+  const q = String(req.query.q || '').trim().toLowerCase();
+  if (!q || q.length < 2) return res.json({ ok: true, contacts: [] });
+  try {
+    const all = getContacts();
+    const matches = all.filter(c => (c.name || '').toLowerCase().includes(q)).slice(0, 25);
+    audit(`search q="${q}" returned ${matches.length} of ${all.length}`);
+    res.json({ ok: true, contacts: matches });
+  } catch (e) {
+    audit(`search ERROR q="${q}": ${e.message}`);
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+app.post('/add', express.json(), (req, res) => {
+  const r = addOverlayContact(req.body && req.body.name, req.body && req.body.phone);
+  if (!r.ok) return res.status(400).json(r);
+  res.json(r);
+});
+
+app.listen(PORT, BIND, () => {
+  audit(`Butlr contacts service (SQLite-direct) listening on ${BIND}:${PORT}`);
+});
diff --git a/routes/admin.js b/routes/admin.js
index 9c90973..2dce504 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -18,9 +18,154 @@ 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]);
 }
diff --git a/routes/public.js b/routes/public.js
index 6324cb7..5403aa7 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -167,6 +167,31 @@ router.post('/new/submit', async (req, res) => {
 // Used by the form at the top of /calls. Auth-gated (mounted under /api/calls
 // prefix → requireOwner). Reuses the immediate-dial path so the worker tick
 // doesn't have to be the one to pick it up.
+// Client-side DNC pre-check — /new wizard hits this on phone-blur so the
+// user sees "this number is blocked" BEFORE submitting the form. Auth-
+// gated. Does NOT log a block (no dial attempt happened); the block log
+// is only written on actual dial attempts inside tick().
+router.get('/api/dnc-check', (req, res) => {
+  if (!req.user) return res.status(401).json({ ok: false, error: 'sign in first' });
+  const phone = String(req.query.phone || '').trim();
+  if (!phone) return res.status(400).json({ ok: false, error: 'phone required' });
+  try {
+    const dnc = require('../lib/dnc-check');
+    const result = dnc.checkPhone(phone, 'precheck');
+    // Don't leak the underlying source detail to non-admins; just allowed/reason.
+    res.json({
+      ok: true,
+      allowed: result.allowed,
+      reason: result.reason || null,
+      dry_run_bypass: !!result.dry_run_bypass,
+    });
+  } catch (e) {
+    // Gate-error (missing snapshot) → block + surface error code so UI
+    // can render "DNC list unavailable; we won't dial this until it's restored"
+    res.json({ ok: true, allowed: false, reason: 'gate_error', code: e.code || 'unknown' });
+  }
+});
+
 router.post('/api/quick-dial', express.urlencoded({ extended: true }), async (req, res) => {
   if (!req.user) return res.status(401).json({ ok: false, error: 'sign in first' });
   const body = req.body || {};
diff --git a/views/admin/calls.ejs b/views/admin/calls.ejs
new file mode 100644
index 0000000..937d8b7
--- /dev/null
+++ b/views/admin/calls.ejs
@@ -0,0 +1,67 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="container" style="max-width: 1200px; padding: 24px;">
+
+  <p style="margin-bottom: 8px;"><a href="/admin">← Admin</a></p>
+  <h1 style="margin: 0 0 6px;">Call history</h1>
+  <p class="muted" style="margin: 0 0 16px;"><%= calls.length %> total · all users · ordered newest first</p>
+
+  <div style="display:flex; gap:12px; margin-bottom:16px; flex-wrap:wrap;">
+    <input id="filter-q" placeholder="filter by business / goal / id" style="flex:1; min-width:240px; padding:8px 12px; border:1px solid var(--line); border-radius:6px;"/>
+    <select id="filter-status" style="padding:8px 12px; border:1px solid var(--line); border-radius:6px;">
+      <option value="">all statuses</option>
+      <option value="queued">queued</option>
+      <option value="dialing">dialing</option>
+      <option value="connected">connected</option>
+      <option value="done">done</option>
+      <option value="failed">failed</option>
+    </select>
+  </div>
+
+  <table style="width:100%; border-collapse:collapse; font-size:13px;">
+    <thead style="background:var(--panel-2);">
+      <tr style="border-bottom:1px solid var(--line);">
+        <th style="text-align:left; padding:8px;">When</th>
+        <th style="text-align:left; padding:8px;">Business</th>
+        <th style="text-align:left; padding:8px;">Goal</th>
+        <th style="text-align:left; padding:8px;">Status</th>
+        <th style="text-align:left; padding:8px;">User</th>
+        <th style="text-align:left; padding:8px;">Listen</th>
+      </tr>
+    </thead>
+    <tbody id="calls-tbody">
+      <% for (const c of calls) { %>
+      <tr class="row" data-status="<%= c.status %>" data-search="<%= ((c.business_name||'') + ' ' + (c.goal||'') + ' ' + c.id).toLowerCase() %>" style="border-bottom:1px solid var(--line);">
+        <td style="padding:8px; white-space:nowrap; color:var(--ink-dim);"><%= new Date(c.created_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) %></td>
+        <td style="padding:8px;"><strong><%= c.business_name %></strong><br><span class="muted" style="font-size:11px;"><%= c.business_phone %></span></td>
+        <td style="padding:8px; max-width:300px;"><span class="muted"><%= (c.goal||'').slice(0,80) %><%= (c.goal||'').length > 80 ? '…' : '' %></span></td>
+        <td style="padding:8px;">
+          <span style="padding:2px 8px; border-radius:4px; font-size:11px; background:<%= c.status === 'done' ? '#dcfce7' : c.status === 'failed' ? '#fee2e2' : c.status === 'connected' ? '#fef3c7' : '#e0e7ff' %>; color:<%= c.status === 'done' ? '#166534' : c.status === 'failed' ? '#991b1b' : c.status === 'connected' ? '#92400e' : '#3730a3' %>;"><%= c.status %></span>
+        </td>
+        <td style="padding:8px; font-size:11px;"><%= (c.user_id || 'legacy').slice(0,8) %></td>
+        <td style="padding:8px;"><a href="/listen/<%= c.id %>">▶</a></td>
+      </tr>
+      <% } %>
+    </tbody>
+  </table>
+
+  <script>
+  const q = document.getElementById('filter-q');
+  const s = document.getElementById('filter-status');
+  function apply() {
+    const ql = (q.value || '').toLowerCase();
+    const sv = s.value;
+    document.querySelectorAll('.row').forEach(r => {
+      const m1 = !ql || r.dataset.search.includes(ql);
+      const m2 = !sv || r.dataset.status === sv;
+      r.style.display = (m1 && m2) ? '' : 'none';
+    });
+  }
+  q.addEventListener('input', apply);
+  s.addEventListener('change', apply);
+  </script>
+
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/contacts.ejs b/views/admin/contacts.ejs
new file mode 100644
index 0000000..415d9c6
--- /dev/null
+++ b/views/admin/contacts.ejs
@@ -0,0 +1,110 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="container" style="max-width: 900px; padding: 24px;">
+
+  <p style="margin-bottom: 8px;"><a href="/admin">← Admin</a></p>
+  <h1 style="margin: 0 0 6px;">Contacts</h1>
+  <p class="muted" style="margin: 0 0 16px;">Lazy-search your Mac Contacts · click any to set goal + dial · <span id="svc-status">checking service…</span></p>
+
+  <input id="q" autofocus placeholder="Search contacts by name (e.g. 'Sam')" style="width:100%; box-sizing:border-box; padding:12px 16px; font-size:16px; border:1px solid var(--line); border-radius:8px; margin-bottom:16px;"/>
+
+  <div id="results" style="display:flex; flex-direction:column; gap:8px;"></div>
+
+  <!-- Dial modal -->
+  <div id="dial-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center;">
+    <div style="background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:520px; width:90%;">
+      <h3 style="margin:0 0 12px;" id="dial-title">Call</h3>
+      <p class="muted" style="margin:0 0 16px;" id="dial-subtitle"></p>
+      <label style="display:block; margin-bottom:12px;">
+        <span class="muted" style="font-size:12px;">Goal for this call</span>
+        <textarea id="dial-goal" rows="3" placeholder="e.g. Quick hello to check in" style="width:100%; box-sizing:border-box; padding:10px; border:1px solid var(--line); border-radius:6px; margin-top:4px;"></textarea>
+      </label>
+      <div style="display:flex; gap:8px; justify-content:flex-end;">
+        <button id="dial-cancel" style="padding:10px 16px;">Cancel</button>
+        <button id="dial-go" style="padding:10px 16px; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; font-weight:600;">📞 Dial via Butlr</button>
+      </div>
+    </div>
+  </div>
+
+  <script>
+  const q = document.getElementById('q');
+  const results = document.getElementById('results');
+  const svcStatus = document.getElementById('svc-status');
+
+  // Check Mac2 contacts service health
+  fetch('/admin/api/contacts-health').then(r => r.json()).then(j => {
+    svcStatus.textContent = j.ok ? `service online — ${j.count} contacts indexed` : 'service offline';
+    svcStatus.style.color = j.ok ? 'var(--ink-dim)' : '#d33';
+  }).catch(() => { svcStatus.textContent = 'service offline'; svcStatus.style.color = '#d33'; });
+
+  let timer = null;
+  q.addEventListener('input', () => {
+    clearTimeout(timer);
+    timer = setTimeout(() => doSearch(q.value.trim()), 200);
+  });
+
+  async function doSearch(query) {
+    if (!query || query.length < 2) { results.innerHTML = ''; return; }
+    results.innerHTML = '<p class="muted">searching…</p>';
+    try {
+      const r = await fetch('/admin/api/contacts-search?q=' + encodeURIComponent(query));
+      const j = await r.json();
+      if (!j.ok) { results.innerHTML = `<p class="muted" style="color:#d33;">${j.error || 'search failed'}</p>`; return; }
+      if (!j.contacts || j.contacts.length === 0) {
+        results.innerHTML = '<p class="muted">no matches</p>';
+        return;
+      }
+      results.innerHTML = j.contacts.map(c => {
+        const phones = (c.phones || []).map(p => `<a href="#" class="phone" data-number="${p}" data-name="${c.name}" style="margin-right:8px; padding:4px 10px; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; border-radius:4px; text-decoration:none; font-size:13px;">📞 ${p}</a>`).join('');
+        return `<div style="padding:12px 16px; border:1px solid var(--line); border-radius:8px; background:var(--panel);">
+          <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
+            <strong>${c.name || '(no name)'}</strong>
+            ${phones || '<span class="muted">no phone</span>'}
+          </div>
+        </div>`;
+      }).join('');
+      // Wire phone clicks
+      document.querySelectorAll('.phone').forEach(el => {
+        el.addEventListener('click', e => {
+          e.preventDefault();
+          openDial(el.dataset.name, el.dataset.number);
+        });
+      });
+    } catch (e) {
+      results.innerHTML = `<p style="color:#d33;">error: ${e.message}</p>`;
+    }
+  }
+
+  // Dial modal
+  const modal = document.getElementById('dial-modal');
+  let pending = null;
+  function openDial(name, number) {
+    pending = { name, number };
+    document.getElementById('dial-title').textContent = `Call ${name}`;
+    document.getElementById('dial-subtitle').textContent = number;
+    document.getElementById('dial-goal').value = '';
+    modal.style.display = 'flex';
+  }
+  document.getElementById('dial-cancel').addEventListener('click', () => { modal.style.display = 'none'; pending = null; });
+  document.getElementById('dial-go').addEventListener('click', async () => {
+    if (!pending) return;
+    const goal = document.getElementById('dial-goal').value.trim() || `Brief test call to ${pending.name}. Be polite. End politely when goodbye.`;
+    const r = await fetch('/admin/api/dial', {
+      method: 'POST',
+      headers: {'Content-Type':'application/json'},
+      body: JSON.stringify({ name: pending.name, number: pending.number, goal })
+    });
+    const j = await r.json();
+    if (j.ok) {
+      window.location = '/listen/' + j.call_id;
+    } else {
+      alert('Dial failed: ' + (j.error || 'unknown'));
+    }
+  });
+
+  </script>
+
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/index.ejs b/views/admin/index.ejs
new file mode 100644
index 0000000..c0eb65d
--- /dev/null
+++ b/views/admin/index.ejs
@@ -0,0 +1,55 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="container" style="max-width: 1100px; padding: 24px;">
+
+  <h1 style="margin: 0 0 6px;">Butlr Admin</h1>
+  <p class="muted" style="margin: 0 0 24px;">Signed in as <strong><%= req.user?.email || req.user?.name || 'admin' %></strong> · role: <%= req.user?.role || 'owner' %></p>
+
+  <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px;">
+
+    <a href="/admin/users" class="admin-card">
+      <div class="admin-card-icon">👥</div>
+      <h3>Users</h3>
+      <p class="muted">Add, remove, set roles · <strong><%= users_count %></strong> total</p>
+    </a>
+
+    <a href="/admin/contacts" class="admin-card">
+      <div class="admin-card-icon">📇</div>
+      <h3>Contacts</h3>
+      <p class="muted">Search your Mac contacts · click-to-call · <span id="contacts-status" class="muted">checking...</span></p>
+    </a>
+
+    <a href="/admin/calls" class="admin-card">
+      <div class="admin-card-icon">📞</div>
+      <h3>Call history</h3>
+      <p class="muted">All calls · <strong><%= calls_count %></strong> total · play + transcript</p>
+    </a>
+
+    <a href="/admin/live" class="admin-card" style="border-color: <%= live_count > 0 ? '#dc2626' : 'var(--line)' %>;">
+      <div class="admin-card-icon"><%= live_count > 0 ? '🔴' : '⚪' %></div>
+      <h3>Live calls</h3>
+      <p class="muted"><strong><%= live_count %></strong> active right now · takeover panel</p>
+    </a>
+
+  </div>
+
+  <style>
+    .admin-card { display: block; padding: 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); text-decoration: none; color: var(--ink); transition: transform 0.15s, border-color 0.15s; }
+    .admin-card:hover { transform: translateY(-2px); border-color: var(--ink-dim); }
+    .admin-card-icon { font-size: 32px; margin-bottom: 8px; }
+    .admin-card h3 { margin: 0 0 6px; font-size: 18px; }
+    .admin-card p { margin: 0; font-size: 13px; }
+  </style>
+
+  <script>
+  // Probe Mac2 contacts service via Butlr proxy. If unreachable, show offline label.
+  fetch('/admin/api/contacts-health').then(r => r.json()).then(j => {
+    const el = document.getElementById('contacts-status');
+    if (el) el.textContent = j.ok ? (j.count + ' available') : 'service offline';
+  }).catch(() => { document.getElementById('contacts-status').textContent = 'service offline'; });
+  </script>
+
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/live.ejs b/views/admin/live.ejs
new file mode 100644
index 0000000..05fbc78
--- /dev/null
+++ b/views/admin/live.ejs
@@ -0,0 +1,42 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="container" style="max-width: 1100px; padding: 24px;">
+
+  <p style="margin-bottom: 8px;"><a href="/admin">← Admin</a></p>
+  <h1 style="margin: 0 0 6px;">Live calls</h1>
+  <p class="muted" style="margin: 0 0 16px;"><strong id="live-count"><%= active.length %></strong> active · auto-refresh every 5s</p>
+
+  <div id="live-grid" style="display:grid; grid-template-columns:repeat(auto-fit, minmax(320px, 1fr)); gap:16px;">
+    <% if (active.length === 0) { %>
+      <div class="muted" style="grid-column:1/-1; padding:48px; text-align:center; border:1px dashed var(--line); border-radius:10px;">No active calls. Place one from <a href="/new">the wizard</a> or <a href="/admin/contacts">contacts</a>.</div>
+    <% } %>
+    <% for (const c of active) { %>
+    <div style="padding:18px; border:1px solid var(--line); border-radius:10px; background:var(--panel);">
+      <div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:8px;">
+        <div>
+          <strong><%= c.business_name %></strong>
+          <div class="muted" style="font-size:11px;"><%= c.business_phone %></div>
+        </div>
+        <span style="padding:2px 10px; border-radius:12px; font-size:11px; background:#fef3c7; color:#92400e; animation:pulse 1.5s infinite;"><%= c.status %></span>
+      </div>
+      <p style="font-size:13px; margin:8px 0;"><span class="muted"><%= (c.goal||'').slice(0,140) %></span></p>
+      <div style="display:flex; gap:8px; margin-top:12px;">
+        <a href="/listen/<%= c.id %>" style="flex:1; padding:8px 14px; background:#dbeafe; border:1px solid #2563eb; color:#1e3a8a; border-radius:6px; text-decoration:none; text-align:center; font-weight:600;">🎩 Listen + Take over</a>
+      </div>
+    </div>
+    <% } %>
+  </div>
+
+  <style>
+    @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
+  </style>
+
+  <script>
+  // Auto-refresh active list every 5s
+  setInterval(() => location.reload(), 5000);
+  </script>
+
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/admin/users.ejs b/views/admin/users.ejs
new file mode 100644
index 0000000..7eaa78b
--- /dev/null
+++ b/views/admin/users.ejs
@@ -0,0 +1,71 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="container" style="max-width: 1100px; padding: 24px;">
+
+  <p style="margin-bottom: 8px;"><a href="/admin">← Admin</a></p>
+  <h1 style="margin: 0 0 6px;">Users</h1>
+  <p class="muted" style="margin: 0 0 16px;"><%= users.length %> total</p>
+
+  <section style="padding:18px; border:1px solid var(--line); border-radius:10px; background:var(--panel); margin-bottom:24px;">
+    <h3 style="margin:0 0 12px;">Add user</h3>
+    <form action="/admin/users/add" method="POST" style="display:grid; grid-template-columns:1fr 1fr 140px 120px; gap:12px; align-items:end;">
+      <label style="display:block;">
+        <span class="muted" style="font-size:12px;">name</span>
+        <input name="name" required style="width:100%; box-sizing:border-box; padding:8px; border:1px solid var(--line); border-radius:6px;"/>
+      </label>
+      <label style="display:block;">
+        <span class="muted" style="font-size:12px;">email</span>
+        <input name="email" required type="email" style="width:100%; box-sizing:border-box; padding:8px; border:1px solid var(--line); border-radius:6px;"/>
+      </label>
+      <label style="display:block;">
+        <span class="muted" style="font-size:12px;">role</span>
+        <select name="role" style="width:100%; box-sizing:border-box; padding:8px; border:1px solid var(--line); border-radius:6px;">
+          <option value="user">user</option>
+          <option value="admin">admin</option>
+        </select>
+      </label>
+      <button type="submit" style="padding:10px 16px;">+ Add</button>
+    </form>
+  </section>
+
+  <table style="width:100%; border-collapse:collapse; font-size:13px;">
+    <thead style="background:var(--panel-2);">
+      <tr style="border-bottom:1px solid var(--line);">
+        <th style="text-align:left; padding:8px;">ID</th>
+        <th style="text-align:left; padding:8px;">Name</th>
+        <th style="text-align:left; padding:8px;">Email</th>
+        <th style="text-align:left; padding:8px;">Role</th>
+        <th style="text-align:left; padding:8px;">Created</th>
+        <th style="text-align:left; padding:8px;">Calls</th>
+        <th></th>
+      </tr>
+    </thead>
+    <tbody>
+      <% for (const u of users) { %>
+      <tr style="border-bottom:1px solid var(--line);">
+        <td style="padding:8px; font-family:monospace; font-size:11px;"><%= u.id %></td>
+        <td style="padding:8px;"><strong><%= u.name || '—' %></strong></td>
+        <td style="padding:8px;"><%= u.email || '—' %></td>
+        <td style="padding:8px;">
+          <span style="padding:2px 8px; border-radius:4px; font-size:11px; background:<%= u.role === 'admin' ? '#fef3c7' : '#e0e7ff' %>; color:<%= u.role === 'admin' ? '#92400e' : '#3730a3' %>;"><%= u.role || 'user' %></span>
+        </td>
+        <td style="padding:8px; color:var(--ink-dim); font-size:11px;"><%= u.created_at ? new Date(u.created_at).toLocaleDateString() : '—' %></td>
+        <td style="padding:8px;"><%= calls_per_user[u.id] || 0 %></td>
+        <td style="padding:8px; text-align:right;">
+          <% if (u.id !== req.user?.id) { %>
+            <form action="/admin/users/<%= u.id %>/delete" method="POST" style="display:inline;" onsubmit="return confirm('Remove user <%= u.email %>?');">
+              <button type="submit" style="padding:4px 10px; font-size:11px; background:#fee2e2; border:1px solid #d33; color:#900; cursor:pointer; border-radius:4px;">remove</button>
+            </form>
+          <% } else { %>
+            <span class="muted" style="font-size:11px;">(you)</span>
+          <% } %>
+        </td>
+      </tr>
+      <% } %>
+    </tbody>
+  </table>
+
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index 5cabeb7..43d1d3c 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -20,7 +20,8 @@
           </label>
           <label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
             <span class="muted">Phone (E.164, +1...)</span>
-            <input name="business_phone" required placeholder="+13034997111" pattern="\+[1-9][0-9]{1,14}" style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px;">
+            <input name="business_phone" id="quick-dial-phone" required placeholder="+13034997111" pattern="\+[1-9][0-9]{1,14}" style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px;">
+            <span id="dnc-status" class="muted small" style="font-size:11px;min-height:14px;"></span>
           </label>
           <label style="grid-column: 1 / -1; display:flex; flex-direction:column; gap:4px; font-size:12px;">
             <span class="muted">Goal (what Butlr should accomplish)</span>
@@ -91,6 +92,34 @@
   const form = document.getElementById('quick-dial-form');
   const btn = document.getElementById('quick-dial-btn');
   const stat = document.getElementById('quick-dial-status');
+  const phoneEl = document.getElementById('quick-dial-phone');
+  const dncStat = document.getElementById('dnc-status');
+
+  // DNC pre-check on phone-blur. If listed, warn + disable dial button.
+  if (phoneEl && dncStat) {
+    phoneEl.addEventListener('blur', async () => {
+      const v = (phoneEl.value || '').trim();
+      if (!v) { dncStat.textContent = ''; btn.disabled = false; return; }
+      dncStat.textContent = 'checking DNC…';
+      try {
+        const r = await fetch('/api/dnc-check?phone=' + encodeURIComponent(v));
+        const j = await r.json();
+        if (j.allowed) {
+          dncStat.textContent = j.dry_run_bypass ? '✓ ok (dry-run mode)' : '✓ not on DNC';
+          dncStat.style.color = 'var(--accent,#1a56db)';
+          btn.disabled = false;
+        } else {
+          dncStat.textContent = '🚫 ' + (j.reason === 'federal_dnc' ? 'on federal DNC' : j.reason === 'internal_suppression' ? 'in internal suppression' : j.reason === 'gate_error' ? 'DNC list unavailable' : 'blocked: ' + j.reason);
+          dncStat.style.color = '#991b1b';
+          btn.disabled = true;
+        }
+      } catch (e) {
+        dncStat.textContent = '(DNC check failed; will re-check on submit)';
+        dncStat.style.color = 'var(--ink-dim)';
+        btn.disabled = false;
+      }
+    });
+  }
   if (form) {
     form.addEventListener('submit', async (e) => {
       e.preventDefault();

← 2a88642 admin/dnc + dnc_flagged surface on /calls  ·  back to Butlr  ·  wizard step 2: DNC pre-check on phone field + directory pres 0dd1395 →