← 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
M lib/data.jsM routes/public.jsM views/public/call.ejsM views/public/wizard-4-callback.ejs
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 →