[object Object]

← back to Butlr

loosen phone validation + add Retry button on call detail + repeat-anyway override on wizard 4

65c8fc1a631e7ece04406f1ad9b6dcaef4ecf4dd · 2026-05-13 12:21:08 -0700 · SteveStudio2

Files touched

Diff

commit 65c8fc1a631e7ece04406f1ad9b6dcaef4ecf4dd
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 12:21:08 2026 -0700

    loosen phone validation + add Retry button on call detail + repeat-anyway override on wizard 4
---
 lib/data.js                        | 11 ++++---
 routes/public.js                   | 62 +++++++++++++++++++++++++++++++++++++-
 views/public/call.ejs              |  7 +++++
 views/public/wizard-4-callback.ejs |  9 ++++++
 4 files changed, 84 insertions(+), 5 deletions(-)

diff --git a/lib/data.js b/lib/data.js
index 20a33e9..468ccde 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -70,13 +70,16 @@ function categoryById(id) {
 }
 
 function validatePhone(p) {
-  // Lightweight US-number sanity check. Strip everything that isn't a digit
-  // and require 10-11 digits (11 = leading 1).
+  // Accept any human-typed format — "(800) 555-1234", "1-800-555-1234",
+  // "+44 20 7946 0958", "800.555.1234", etc. We strip everything that isn't
+  // a digit, then best-effort E.164 normalize. Only a totally-empty input
+  // (or one with no digits at all) is rejected; Twilio will surface any
+  // remaining unreachable-number errors on the actual dial attempt.
   const digits = String(p || '').replace(/\D/g, '');
+  if (!digits) return null;
   if (digits.length === 10) return '+1' + digits;
   if (digits.length === 11 && digits.startsWith('1')) return '+' + digits;
-  if (digits.length >= 8 && digits.length <= 15) return '+' + digits;  // intl best-effort
-  return null;
+  return '+' + digits;  // intl / short-code / any other format — let Twilio judge
 }
 
 function sanitizeRow(body) {
diff --git a/routes/public.js b/routes/public.js
index 5403aa7..149fdbf 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -144,7 +144,12 @@ router.post('/new/submit', async (req, res) => {
     // For v1 simplicity, redirect to /signup with return URL = /new (lose draft).
     return res.redirect(302, '/signup?return=' + encodeURIComponent('/new'));
   }
-  const r = data.addCall(req.body || {}, req.user.id);
+  // Coerce form-encoded allow_repeat to the strict boolean addCall expects.
+  // Only honored when the user explicitly clicks "Retry anyway" after a
+  // repeat_call_blocked error — see wizard-4-callback.ejs.
+  const body = { ...(req.body || {}) };
+  if (body.allow_repeat === 'true' || body.allow_repeat === 'on') body.allow_repeat = true;
+  const r = data.addCall(body, req.user.id);
   if (!r.ok) {
     const state = pickState(req.body);
     return res.status(400).render('public/wizard-4-callback', {
@@ -222,6 +227,61 @@ router.post('/api/quick-dial', express.urlencoded({ extended: true }), async (re
   res.json({ ok: true, id: r.call.id });
 });
 
+// Retry — duplicate a prior call's payload and submit a fresh queued one.
+// Hard rule: only allow retry from failed/canceled status (data.js already
+// exempts these from the once-per-phone block, so we don't auto-flip
+// allow_repeat — the retry IS the explicit user action that the standing
+// rule permits). For repeat_call_blocked errors on an active prior call,
+// the wizard surfaces a separate "retry anyway" form below the error.
+router.post('/new/retry/:id', async (req, res) => {
+  if (!req.user) return res.redirect(302, '/signup');
+  const id = req.params.id;
+  if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
+  const prior = data.getCall(id, req.user.id, true);  // reveal=true so we copy account fields
+  if (!prior) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
+  if (!['failed', 'canceled', 'cancelled', 'done'].includes(prior.status)) {
+    return res.status(400).render('public/error', {
+      title: 'Cannot retry — Butlr',
+      message: `Call ${id} is still ${prior.status}. Only failed/canceled/done calls can be retried.`,
+    });
+  }
+  const retryRow = {
+    category: prior.category,
+    business_name: prior.business_name,
+    business_phone: prior.business_phone,
+    goal: prior.goal,
+    notes: prior.notes,
+    account_number: prior.account_number,
+    last4_ssn: prior.last4_ssn,
+    billing_zip: prior.billing_zip,
+    date_of_birth: prior.date_of_birth,
+    auth_password: prior.auth_password,
+    callback_name: prior.callback_name,
+    callback_phone: prior.callback_phone,
+    callback_email: prior.callback_email,
+    best_time_window: prior.best_time_window,
+    max_hold_minutes: prior.max_hold_minutes,
+    max_spend_cents: prior.max_spend_cents,
+    consent_recording: prior.consent_recording,
+    consent_terms: true,  // prior call had consent; retry inherits
+    notify_sms: prior.notify_sms,
+    notify_email: prior.notify_email,
+    allow_repeat: true,  // explicit user action — the Retry button itself is the opt-in
+  };
+  const r = data.addCall(retryRow, req.user.id);
+  if (!r.ok) {
+    return res.status(400).render('public/error', {
+      title: 'Retry failed — Butlr',
+      message: 'Could not create retry: ' + (r.errors || []).join('; '),
+    });
+  }
+  try {
+    const twilio = require('../lib/twilio');
+    twilio.dialCall(r.call).catch(e => console.error('[retry-dial]', e.message));
+  } catch (e) { console.error('[retry-dial-spawn]', e.message); }
+  res.redirect('/calls/' + r.call.id + '/live');
+});
+
 // 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/views/public/call.ejs b/views/public/call.ejs
index 6220844..d043921 100644
--- a/views/public/call.ejs
+++ b/views/public/call.ejs
@@ -11,6 +11,13 @@
         <span class="status status-<%= call.status %>"><%= call.status %></span>
       </header>
 
+      <% if (['failed','canceled','cancelled','done'].includes(call.status)) { %>
+        <form method="POST" action="/new/retry/<%= call.id %>" class="retry-form" style="margin:12px 0 24px">
+          <button type="submit" class="btn primary">↻ Retry this call</button>
+          <span class="muted small" style="margin-left:10px">Re-queues the same number, goal, and account info as a new call.</span>
+        </form>
+      <% } %>
+
       <p class="muted">
         <%= call.category_label %> · <%= call.business_phone %> · queued <%= call.created_at.replace('T',' ').slice(0,16) %>
       </p>
diff --git a/views/public/wizard-4-callback.ejs b/views/public/wizard-4-callback.ejs
index 15e4c4f..a9b1e77 100644
--- a/views/public/wizard-4-callback.ejs
+++ b/views/public/wizard-4-callback.ejs
@@ -12,6 +12,15 @@
 
       <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
         <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+        <% var repeatBlocked = errors.some(function(e){ return String(e).indexOf('repeat_call_blocked') === 0; }); %>
+        <% if (repeatBlocked) { %>
+          <form method="POST" action="/new/submit" style="margin:12px 0 24px;padding:12px;border:1px solid var(--line);border-radius:8px;background:rgba(255,210,80,0.08)">
+            <%- include('../partials/wizard-state', { state, skip: [] }) %>
+            <input type="hidden" name="allow_repeat" value="true">
+            <p class="muted small" style="margin:0 0 8px"><strong>Retry anyway?</strong> Butlr already dialed this number for you and the prior call hasn't been marked failed or canceled. Click below to queue a second call to the same number.</p>
+            <button type="submit" class="btn primary">↻ Retry anyway — dial this number again</button>
+          </form>
+        <% } %>
       <% } %>
 
       <form method="POST" action="/new/submit" class="wizard-form" autocomplete="off">

← 96ffa09 snapshot: backup uncommitted work (4 files)  ·  back to Butlr  ·  cap repeat dials at 4 per phone — even with allow_repeat, su f848efc →