[object Object]

← back to Butlr

calls: quick-dial form + inline transcript viewer + listen-live link

3f6963c86322d6861742a12008a7c79f0bb52f0d · 2026-05-13 07:18:22 -0700 · SteveStudio2

Per Steve's "allow user to dial from here and listen" — the /calls page
now does all three:
- Top-of-page Quick dial form (business name + phone + goal). POSTs
  /api/quick-dial which uses immediate-dial path so call moves the
  instant the form returns.
- Per-row "🔊 Listen live →" chip when status is queued/dialing/
  on_hold/connected, links to /calls/<id>/live (gesture-unlock + WSS).
- Per-row inline <audio> when status is done/failed/completed (hidden
  unless /has-recording confirms MP3 exists).
- Per-row "📝 transcript" toggle button (hidden unless /has-transcript
  says it exists). Click to expand inline below the audio; renders
  segments with start-time prefixes, falls back to raw text or JSON.
- Auto-poll status every 5s so rows transition live→archived without
  page reload.

routes/public.js: new POST /api/quick-dial handler, validates body,
falls back to env phone if user has no email_phone, kicks immediate
dial in-process, returns {ok, id}.

Files touched

Diff

commit 3f6963c86322d6861742a12008a7c79f0bb52f0d
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 07:18:22 2026 -0700

    calls: quick-dial form + inline transcript viewer + listen-live link
    
    Per Steve's "allow user to dial from here and listen" — the /calls page
    now does all three:
    - Top-of-page Quick dial form (business name + phone + goal). POSTs
      /api/quick-dial which uses immediate-dial path so call moves the
      instant the form returns.
    - Per-row "🔊 Listen live →" chip when status is queued/dialing/
      on_hold/connected, links to /calls/<id>/live (gesture-unlock + WSS).
    - Per-row inline <audio> when status is done/failed/completed (hidden
      unless /has-recording confirms MP3 exists).
    - Per-row "📝 transcript" toggle button (hidden unless /has-transcript
      says it exists). Click to expand inline below the audio; renders
      segments with start-time prefixes, falls back to raw text or JSON.
    - Auto-poll status every 5s so rows transition live→archived without
      page reload.
    
    routes/public.js: new POST /api/quick-dial handler, validates body,
    falls back to env phone if user has no email_phone, kicks immediate
    dial in-process, returns {ok, id}.
---
 routes/public.js             |  34 ++++++++
 scripts/inject-test-calls.js |  89 ++++++++++++++++++++
 views/public/calls.ejs       | 194 +++++++++++++++++++++++++++++++++++--------
 3 files changed, 281 insertions(+), 36 deletions(-)

diff --git a/routes/public.js b/routes/public.js
index 9ad02f2..6324cb7 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -163,6 +163,40 @@ router.post('/new/submit', async (req, res) => {
   res.redirect('/calls/' + r.call.id + '/live');
 });
 
+// Quick-dial: minimal-fields POST that places a call without the 4-step wizard.
+// 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.
+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 || {};
+  // Apply sane defaults so addCall validation passes.
+  const callRow = {
+    business_name: body.business_name,
+    business_phone: body.business_phone,
+    goal: body.goal,
+    callback_phone: req.user.email_phone || process.env.TWILIO_PHONE_NUMBER || '+16067130489',
+    callback_name: (req.user.email || '').split('@')[0] || 'You',
+    callback_email: req.user.email || '',
+    category: body.category || 'other',
+    category_label: 'Quick dial',
+    max_hold_minutes: 2,
+    max_spend_cents: 50,
+    consent_recording: 'on',
+    consent_terms: 'on',
+    notify_sms: '',
+    notify_email: '',
+    notes: 'placed via /calls quick-dial',
+  };
+  const r = data.addCall(callRow, req.user.id);
+  if (!r.ok) return res.status(400).json({ ok: false, error: (r.errors || []).join('; ') });
+  try {
+    const twilio = require('../lib/twilio');
+    twilio.dialCall(r.call).catch(e => console.error('[quick-dial]', e.message));
+  } catch (e) { console.error('[quick-dial-spawn]', e.message); }
+  res.json({ ok: true, id: r.call.id });
+});
+
 // LIVE call page — popup UX with status polling + AI chat side-panel
 router.get('/calls/:id/live', (req, res) => {
   const id = req.params.id;
diff --git a/scripts/inject-test-calls.js b/scripts/inject-test-calls.js
new file mode 100644
index 0000000..5b08596
--- /dev/null
+++ b/scripts/inject-test-calls.js
@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+// Inject 5 test calls into the queue. Worker (lib/twilio tick) picks
+// them up within 5s of the next poll, dials Twilio, drives AI agent
+// through IVR, records, finalizes MP3.
+//
+// Steve approved targets (2026-05-13): pure-automated lines only, no
+// humans bothered. Total cost ~$0.10-$0.50 Twilio.
+//
+// Run on prod:
+//   ssh root@45.61.58.125 'cd /root/public-projects/butlr && node scripts/inject-test-calls.js'
+
+const data = require('../lib/data');
+const users = require('../lib/users');
+
+const TARGETS = [
+  {
+    business_name: 'NIST Time Service',
+    business_phone: '+13034997111',
+    category: 'other',
+    category_label: 'Test — automated time service',
+    goal: 'This number reads the current time aloud, then hangs up. There is NO IVR menu. Just listen for ~10 seconds, then say nothing and let the call complete naturally. Do not press any digits. Do not speak.',
+  },
+  {
+    business_name: 'Verizon TN Readback',
+    business_phone: '+18004444444',
+    category: 'other',
+    category_label: 'Test — number readback',
+    goal: 'This is a Verizon test line that reads back the calling number. Listen for ~10 seconds for the readback, then hang up. Do not press digits. Do not speak.',
+  },
+  {
+    business_name: 'USPS Tracking',
+    business_phone: '+18002758777',
+    category: 'shipping',
+    category_label: 'Test — package tracking IVR',
+    goal: 'Navigate the IVR to hear current customer service hours. Listen carefully to the first menu, then press the digit for "hours of operation" or "store information" if offered. If no clear option, press 0 for operator. As soon as you hear the hours announced, hang up.',
+  },
+  {
+    business_name: 'IRS Automated',
+    business_phone: '+18008291040',
+    category: 'gov',
+    category_label: 'Test — federal tax IVR',
+    goal: 'Navigate the IRS phone menu to identify the current wait time for individual income tax questions. The first prompt asks for English (press 1) or Spanish (press 2). Press 1 for English. Then listen for menu options and press the digit for individual tax questions. As soon as you hear a wait-time announcement, hang up. Do not wait for an agent.',
+  },
+  {
+    business_name: 'Capital One',
+    business_phone: '+18002274825',
+    category: 'banking',
+    category_label: 'Test — credit card IVR',
+    goal: 'Get the current customer-service hours from the IVR. Press 0 or say "representative" if needed to reach a menu that announces hours. As soon as hours are announced (or the IVR says "we are open 24 hours"), hang up. Do not actually wait for a representative.',
+  },
+];
+
+async function main() {
+  // Find the admin user to attach calls to.
+  try { users.seedLegacyAdmin(); } catch {}
+  const admin = require('fs').readFileSync(require('path').join(__dirname, '..', 'data', 'users.json'), 'utf8');
+  const adminUser = JSON.parse(admin).find(u => u.role === 'admin');
+  if (!adminUser) { console.error('no admin user'); process.exit(1); }
+  console.log(`injecting 5 test calls under admin: ${adminUser.email}`);
+
+  const created = [];
+  for (const t of TARGETS) {
+    const r = data.addCall({
+      ...t,
+      callback_phone: '+16067130489',  // Steve's Twilio number — bridge won't fire because goal says hang up after info
+      callback_name: 'Steve Abrams',
+      callback_email: 'steve@designerwallcoverings.com',
+      max_hold_minutes: 2,
+      max_spend_cents: 50,
+      consent_recording: 'on',
+      consent_terms: 'on',
+      notify_sms: '',
+      notify_email: '',
+      notes: 'LIVE TEST — autonomous loop tick 7 — 2026-05-13',
+    }, adminUser.id);
+    if (!r.ok) {
+      console.error(`  ✗ ${t.business_name}: ${(r.errors || []).join('; ')}`);
+      continue;
+    }
+    created.push({ id: r.call.id, business: t.business_name, phone: t.business_phone });
+    console.log(`  + ${r.call.id}  ${t.business_name.padEnd(24)}  ${t.business_phone}`);
+  }
+  console.log('\nqueued 5 calls. worker tick picks them up every 5s.');
+  console.log('monitor: pm2 logs butlr --lines 50 | grep -E "twilio|ai-agent|stream"');
+  console.log('listen:  https://butlr.agentabrams.com/calls');
+  console.log('\ncall ids:', created.map(c => c.id).join(' '));
+}
+
+main().catch(e => { console.error('error:', e.message); process.exit(2); });
diff --git a/views/public/calls.ejs b/views/public/calls.ejs
index e574583..fefc610 100644
--- a/views/public/calls.ejs
+++ b/views/public/calls.ejs
@@ -9,18 +9,42 @@
         <a class="btn primary" href="/new">Start a new call →</a>
       </header>
 
+      <!-- QUICK DIAL — minimal form, place a call without the full wizard -->
+      <section style="margin:14px 0 22px; padding:14px 16px; border:1px solid var(--line); border-radius:10px; background:var(--panel);">
+        <strong style="font-size:14px;">⚡ Quick dial</strong>
+        <p class="muted small" style="margin:.25rem 0 .75rem">Enter business + goal, we'll dial right now.</p>
+        <form id="quick-dial-form" style="display:grid; gap:.5rem; grid-template-columns: 1fr 1fr; align-items:end;">
+          <label style="display:flex; flex-direction:column; gap:4px; font-size:12px;">
+            <span class="muted">Business name</span>
+            <input name="business_name" required placeholder="e.g. NIST Time" style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px;">
+          </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;">
+          </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>
+            <textarea name="goal" required rows="2" placeholder="e.g. Get the current store hours, then hang up." style="padding:.5rem .65rem; border:1px solid var(--line); border-radius:6px; resize:vertical;"></textarea>
+          </label>
+          <div style="grid-column: 1 / -1; display:flex; gap:.5rem; align-items:center;">
+            <button class="btn primary" type="submit" id="quick-dial-btn">📞 Dial now</button>
+            <span id="quick-dial-status" class="muted small"></span>
+          </div>
+        </form>
+      </section>
+
       <% if (!calls.length) { %>
         <div class="empty-state">
-          <p class="muted">No calls yet. <a href="/new">Queue your first call →</a></p>
+          <p class="muted">No calls yet. Use Quick dial above or the full <a href="/new">wizard →</a></p>
         </div>
       <% } else { %>
         <ul class="call-list">
           <% calls.forEach(function(c) { %>
-            <li class="call-row">
+            <li class="call-row" data-call-id="<%= c.id %>">
               <a class="call-row-main" href="/calls/<%= c.id %>">
                 <div class="call-row-head">
                   <strong><%= c.business_name %></strong>
-                  <span class="status status-<%= c.status %>"><%= c.status %></span>
+                  <span class="status status-<%= c.status %>" data-status-pill><%= c.status %></span>
                 </div>
                 <p class="muted small">
                   <%= c.category_label %> ·
@@ -29,46 +53,144 @@
                 </p>
                 <p class="call-row-goal"><%= c.goal.length > 140 ? c.goal.slice(0,140)+'…' : c.goal %></p>
               </a>
-              <% if (['done','completed','failed'].includes(c.status)) { %>
-                <div class="call-row-audio" data-call-id="<%= c.id %>" style="margin-top:.5rem; padding:.5rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:6px; display:none;">
-                  <div style="display:flex; align-items:center; gap:.5rem; font-size:11px; color:var(--ink-dim); margin-bottom:4px;">
-                    <span>📼 Recording</span>
-                    <a class="transcript-chip" data-call-id="<%= c.id %>" href="/api/calls/<%= c.id %>/transcript" target="_blank" style="display:none; margin-left:auto; padding:2px 8px; border-radius:9999px; background:var(--accent,#1a56db); color:#fff; text-decoration:none; font-size:10px;">📝 transcript</a>
-                  </div>
-                  <audio controls preload="none" src="/api/calls/<%= c.id %>/recording" style="width:100%; height:32px;"></audio>
+
+              <!-- IN-FLIGHT: listen-live link (hidden unless status is active) -->
+              <div class="call-row-live" data-status-target="<%= c.id %>" style="margin-top:.4rem; <%= ['queued','dialing','on_hold','connected'].includes(c.status) ? '' : 'display:none' %>">
+                <a class="btn ghost" href="/calls/<%= c.id %>/live" style="font-size:12px; padding:6px 12px;">🔊 Listen live →</a>
+              </div>
+
+              <!-- COMPLETED: inline recording + transcript -->
+              <div class="call-row-audio" data-call-id="<%= c.id %>" style="margin-top:.5rem; padding:.5rem .75rem; background:var(--panel-2); border:1px solid var(--line); border-radius:6px; display:none;">
+                <div style="display:flex; align-items:center; gap:.5rem; font-size:11px; color:var(--ink-dim); margin-bottom:4px;">
+                  <span>📼 Recording</span>
+                  <button class="transcript-toggle" type="button" data-call-id="<%= c.id %>" style="display:none; margin-left:auto; padding:2px 10px; border-radius:9999px; background:var(--accent,#1a56db); color:#fff; border:none; cursor:pointer; font-size:10px;">📝 transcript</button>
                 </div>
-              <% } %>
+                <audio controls preload="none" src="/api/calls/<%= c.id %>/recording" style="width:100%; height:32px;"></audio>
+                <div class="transcript-body" data-call-id="<%= c.id %>" style="display:none; margin-top:.5rem; padding:.5rem .65rem; max-height:240px; overflow-y:auto; background:var(--panel); border:1px solid var(--line); border-radius:6px; font-size:12px; line-height:1.5;"></div>
+              </div>
             </li>
           <% }); %>
-
-          <script>
-          // Each row's audio block hides itself by default; probe HEAD on
-          // /api/calls/:id/recording and reveal only if the MP3 actually
-          // exists. Avoids "your browser does not support" + 404 noise on
-          // rows whose call ended before recording was archived.
-          document.querySelectorAll('.call-row-audio').forEach(async (el) => {
-            const id = el.dataset.callId;
-            try {
-              const r = await fetch('/api/calls/' + id + '/has-recording');
-              const j = await r.json();
-              if (j && j.ok && j.exists) el.style.display = '';
-            } catch {}
-          });
-          // Transcript chip: only reveal if /has-transcript says it exists.
-          // Independent of recording — transcripts can land before the MP3.
-          document.querySelectorAll('.transcript-chip').forEach(async (el) => {
-            const id = el.dataset.callId;
-            try {
-              const r = await fetch('/api/calls/' + id + '/has-transcript');
-              const j = await r.json();
-              if (j && j.ok && j.exists) el.style.display = '';
-            } catch {}
-          });
-          </script>
         </ul>
       <% } %>
     </div>
   </section>
 </main>
 
+<script>
+(function () {
+
+  // ── Quick dial ──────────────────────────────────────────────────────
+  const form = document.getElementById('quick-dial-form');
+  const btn = document.getElementById('quick-dial-btn');
+  const stat = document.getElementById('quick-dial-status');
+  if (form) {
+    form.addEventListener('submit', async (e) => {
+      e.preventDefault();
+      btn.disabled = true;
+      stat.textContent = 'placing call…';
+      const fd = new FormData(form);
+      const body = new URLSearchParams();
+      body.set('business_name', fd.get('business_name'));
+      body.set('business_phone', fd.get('business_phone'));
+      body.set('goal', fd.get('goal'));
+      try {
+        const r = await fetch('/api/quick-dial', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
+        const j = await r.json();
+        if (!j.ok) { stat.textContent = '✗ ' + (j.error || 'failed'); btn.disabled = false; return; }
+        stat.textContent = '✓ queued ' + j.id + ' — reloading…';
+        setTimeout(() => location.reload(), 600);
+      } catch (err) {
+        stat.textContent = '✗ network error: ' + err.message;
+        btn.disabled = false;
+      }
+    });
+  }
+
+  // ── Probe each completed row for recording/transcript ──────────────
+  function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]); }
+  function renderTranscript(t) {
+    if (!t) return '<em class="muted">no transcript</em>';
+    if (typeof t === 'string') return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(t) + '</pre>';
+    if (t.text) return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(t.text) + '</pre>';
+    if (Array.isArray(t.segments)) {
+      return t.segments.map(s => {
+        const start = (s.start !== undefined) ? '<span class="muted" style="font-size:10px; min-width:48px; display:inline-block;">[' + Number(s.start).toFixed(1) + 's]</span> ' : '';
+        return start + escapeHtml(s.text || '');
+      }).join('<br>');
+    }
+    return '<pre style="white-space:pre-wrap; margin:0;">' + escapeHtml(JSON.stringify(t, null, 2)) + '</pre>';
+  }
+  document.querySelectorAll('.call-row-audio').forEach(async (el) => {
+    const id = el.dataset.callId;
+    try {
+      const r = await fetch('/api/calls/' + id + '/has-recording');
+      const j = await r.json();
+      if (j && j.ok && j.exists) el.style.display = '';
+    } catch {}
+  });
+  document.querySelectorAll('.transcript-toggle').forEach(async (btn) => {
+    const id = btn.dataset.callId;
+    try {
+      const r = await fetch('/api/calls/' + id + '/has-transcript');
+      const j = await r.json();
+      if (!(j && j.ok && j.exists)) return;
+      btn.style.display = '';
+      let loaded = false, cached = null;
+      btn.addEventListener('click', async () => {
+        const body = document.querySelector('.transcript-body[data-call-id="' + id + '"]');
+        if (!body) return;
+        const show = body.style.display === 'none';
+        if (show && !loaded) {
+          body.innerHTML = '<em class="muted">loading…</em>';
+          try {
+            const rr = await fetch('/api/calls/' + id + '/transcript');
+            const jj = await rr.json();
+            cached = jj.ok ? renderTranscript(jj.transcript) : '<em class="muted">' + (jj.error || 'load failed') + '</em>';
+          } catch (e) { cached = '<em class="muted">network error</em>'; }
+          loaded = true;
+        }
+        if (show) { body.innerHTML = cached; body.style.display = ''; }
+        else { body.style.display = 'none'; }
+      });
+    } catch {}
+  });
+
+  // ── Live status polling for in-flight rows ──────────────────────────
+  // Every 5s: for each row whose pill is queued/dialing/on_hold/connected,
+  // refetch /api/calls/:id/status. When it flips to done/failed, swap the
+  // listen-live chip for the recording block (HEAD-probe + reveal).
+  async function pollLive() {
+    const liveRows = document.querySelectorAll('.call-row-live[data-status-target]');
+    for (const row of liveRows) {
+      if (row.style.display === 'none') continue;
+      const id = row.dataset.statusTarget;
+      try {
+        const r = await fetch('/api/calls/' + id + '/status', { cache: 'no-store' });
+        const j = await r.json();
+        if (!j.ok) continue;
+        // Update pill
+        const pill = document.querySelector('.call-row[data-call-id="' + id + '"] [data-status-pill]');
+        if (pill && pill.textContent !== j.status) {
+          pill.textContent = j.status;
+          pill.className = 'status status-' + j.status;
+        }
+        if (['done','completed','failed'].includes(j.status)) {
+          row.style.display = 'none';
+          // Reveal recording block when MP3 exists
+          const audio = document.querySelector('.call-row-audio[data-call-id="' + id + '"]');
+          if (audio && audio.style.display === 'none') {
+            const hr = await fetch('/api/calls/' + id + '/has-recording');
+            const hj = await hr.json();
+            if (hj && hj.ok && hj.exists) audio.style.display = '';
+          }
+        }
+      } catch {}
+    }
+  }
+  pollLive();
+  setInterval(pollLive, 5000);
+
+})();
+</script>
+
 <%- include('../partials/footer') %>

← f977833 admin-gate + has-transcript: unit tests + 5 routes/listen.js  ·  back to Butlr  ·  predeploy-guard: abort deploy if any call is in flight 6fe29bf →