← back to Butlr
cap repeat dials at 4 per phone — even with allow_repeat, surface count on call detail
f848efc96a8cdbe97ba40a65f2ce12f2eab43213 · 2026-05-13 12:24:30 -0700 · SteveStudio2
Files touched
M lib/data.jsM routes/public.jsM views/public/call.ejs
Diff
commit f848efc96a8cdbe97ba40a65f2ce12f2eab43213
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 12:24:30 2026 -0700
cap repeat dials at 4 per phone — even with allow_repeat, surface count on call detail
---
lib/data.js | 48 +++++++++++++++++++++++++++++++++++++++++-------
routes/public.js | 3 +++
views/public/call.ejs | 20 ++++++++++++++++----
3 files changed, 60 insertions(+), 11 deletions(-)
diff --git a/lib/data.js b/lib/data.js
index 468ccde..cd5a162 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -69,6 +69,22 @@ function categoryById(id) {
return CATEGORIES.find(c => c.id === id) || null;
}
+// Count how many calls have been placed to a phone number (any status).
+// Used by the call-detail page to hide the Retry button when the 4-call cap
+// is reached. Scoped to userId so users don't see each other's call counts.
+function countCallsToPhone(phone, userId) {
+ if (!phone || !userId) return 0;
+ // Compare on the canonical E.164 form so '(800) 555-1234', '8005551234',
+ // and '+18005551234' all collapse to the same key.
+ const target = validatePhone(phone);
+ if (!target) return 0;
+ return readAll().filter(c => {
+ if (c.user_id !== userId) return false;
+ if (!c.business_phone) return false;
+ return validatePhone(c.business_phone) === target;
+ }).length;
+}
+
function validatePhone(p) {
// 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
@@ -135,14 +151,17 @@ function addCall(body, userId) {
// the body explicitly carries allow_repeat=true. Failed/canceled calls
// are exempt — Steve can retry a call that didn't connect.
// See feedback_butlr_call_once_per_person.md.
+ // Compare on canonical E.164 so different formats of the same number
+ // ('(800) 555-1234', '8005551234', '+18005551234') all match.
+ const targetE164 = validatePhone(body.business_phone);
+ const allPriors = readAll().filter(c => {
+ if (c.user_id !== userId) return false;
+ if (!c.business_phone) return false;
+ return validatePhone(c.business_phone) === targetE164;
+ });
+
if (body.allow_repeat !== true) {
- const targetNorm = String(body.business_phone || '').replace(/[^\d+]/g, '');
- const prior = readAll().find(c => {
- if (!c.business_phone) return false;
- if (['canceled','failed'].includes(c.status)) return false;
- const priorNorm = String(c.business_phone || '').replace(/[^\d+]/g, '');
- return priorNorm === targetNorm;
- });
+ const prior = allPriors.find(c => !['canceled','failed'].includes(c.status));
if (prior) {
return {
ok: false,
@@ -151,6 +170,19 @@ function addCall(body, userId) {
}
}
+ // ── Hard cap: 4 calls max to any single phone (2026-05-13) ─────────────
+ // Even with allow_repeat=true, never let a single number be dialed more
+ // than 4 times total. This protects the recipient regardless of how the
+ // user opts in via retry buttons. Counts ALL prior calls (including
+ // failed/canceled) so a number can't be ground down by repeated retries.
+ const MAX_CALLS_PER_PHONE = 4;
+ if (allPriors.length >= MAX_CALLS_PER_PHONE) {
+ return {
+ ok: false,
+ errors: [`max_retries_reached: Butlr has already dialed ${body.business_phone} ${allPriors.length} times (cap is ${MAX_CALLS_PER_PHONE}). No further calls to this number are permitted.`],
+ };
+ }
+
// sanitizeRow has the plaintext values; encrypt sensitive fields before persisting.
const plaintextRow = sanitizeRow(body);
plaintextRow.user_id = userId;
@@ -273,6 +305,8 @@ function businessesForCategory(catId) {
module.exports = {
CATEGORIES, categoryById,
validatePhone, sanitizeRow,
+ MAX_CALLS_PER_PHONE: 4,
+ countCallsToPhone,
addCall, listCalls, listCallsUnscoped, getCall, getCallUnscoped, updateStatus,
patchRow, setTwilioSid, setRecordingMeta, setTranscriptMeta,
loadBusinesses, businessBySlug, businessesForCategory,
diff --git a/routes/public.js b/routes/public.js
index 149fdbf..f25ef37 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -366,11 +366,14 @@ router.get('/calls/:id', (req, res) => {
const c = data.getCall(id, req.user && req.user.id, wantReveal);
if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+ const callCount = data.countCallsToPhone(c.business_phone, req.user && req.user.id);
res.render('public/call', {
title: c.business_name + ' — call detail — Butlr',
meta_desc: 'Call status and submitted details.',
call: c,
revealed: wantReveal,
+ callCount,
+ maxCalls: data.MAX_CALLS_PER_PHONE,
});
});
diff --git a/views/public/call.ejs b/views/public/call.ejs
index d043921..5b549ac 100644
--- a/views/public/call.ejs
+++ b/views/public/call.ejs
@@ -12,10 +12,22 @@
</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>
+ <% var capped = (typeof callCount !== 'undefined' && typeof maxCalls !== 'undefined') && callCount >= maxCalls; %>
+ <% if (capped) { %>
+ <p class="muted small" style="margin:12px 0 24px;padding:10px;border:1px solid var(--line);border-radius:6px;background:rgba(255,80,80,0.06)">
+ <strong>Retry limit reached.</strong> Butlr has dialed this number <%= callCount %> times — the cap is <%= maxCalls %>. No further retries permitted.
+ </p>
+ <% } else { %>
+ <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.
+ <% if (typeof callCount !== 'undefined' && typeof maxCalls !== 'undefined') { %>
+ · <%= callCount %> of <%= maxCalls %> calls used.
+ <% } %>
+ </span>
+ </form>
+ <% } %>
<% } %>
<p class="muted">
← 65c8fc1 loosen phone validation + add Retry button on call detail +
·
back to Butlr
·
sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA 4e0a5bc →