← 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
A mac2-contacts-service/server.jsM routes/admin.jsM routes/public.jsA views/admin/calls.ejsA views/admin/contacts.ejsA views/admin/index.ejsA views/admin/live.ejsA views/admin/users.ejsM views/public/calls.ejs
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 →