[object Object]

← 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

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 →