← back to Butlr
lib/data.js
314 lines
// JSON-backed call queue. data/calls.json is gitignored — each tester's
// queue stays out of the repo. Sensitive fields (account number, SSN last-4,
// auth password) are stored but never echoed back in list views; only
// surfaced on the detail page behind a "Reveal" toggle.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const fieldCrypto = require('./crypto');
const FILE = path.join(__dirname, '..', 'data', 'calls.json');
// Fields encrypted at rest with AES-256-GCM (see lib/crypto.js).
const SENSITIVE_FIELDS = ['account_number', 'last4_ssn', 'billing_zip', 'date_of_birth', 'auth_password'];
function readAll() {
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
}
function writeAll(rows) {
fs.mkdirSync(path.dirname(FILE), { recursive: true });
fs.writeFileSync(FILE, JSON.stringify(rows, null, 2));
}
function newId() {
return crypto.randomBytes(6).toString('base64url');
}
function maskAccount(s) {
if (!s) return '';
const t = String(s).replace(/\s+/g, '');
if (t.length <= 4) return '••••';
return '•'.repeat(Math.max(0, t.length - 4)) + t.slice(-4);
}
// Categories Steve named + a few of the obvious adjacencies. Used to pre-fill
// "what kind of call is this" so the UX can show category-appropriate fields.
const CATEGORIES = [
{ id: 'credit-card-increase', label: 'Credit card — limit increase', icon: '💳',
needsAccount: true, defaultGoal: 'Request a credit-limit increase from $X to $Y on this account.' },
{ id: 'credit-card-dispute', label: 'Credit card — dispute a charge', icon: '⚖️',
needsAccount: true, defaultGoal: 'Dispute charge of $X dated MM/DD from MERCHANT. Reason: ___.' },
{ id: 'hospital-billing', label: 'Hospital — billing dispute', icon: '🏥',
needsAccount: true, defaultGoal: 'Question / dispute bill #___ for date of service MM/DD. Reason: ___.' },
{ id: 'hospital-appointment', label: 'Hospital — schedule appointment', icon: '📅',
needsAccount: false, defaultGoal: 'Schedule an appointment with Dr. ___ on/around MM/DD for ___.' },
{ id: 'restaurant-reservation', label: 'Restaurant — make a reservation', icon: '🍽️',
needsAccount: false, defaultGoal: 'Book a table for ___ people on MM/DD at HH:MM. Name: ___.' },
{ id: 'airline-change', label: 'Airline — change / refund', icon: '✈️',
needsAccount: true, defaultGoal: 'Change flight CONFIRMATION from MM/DD to MM/DD. Or request refund per policy.' },
{ id: 'cable-cancel', label: 'Cable / ISP / wireless — cancel', icon: '📡',
needsAccount: true, defaultGoal: 'Cancel service effective MM/DD. Confirm no early-termination fee.' },
{ id: 'insurance-claim', label: 'Insurance — claim status / appeal', icon: '🛡️',
needsAccount: true, defaultGoal: 'Check status of claim # ___ filed on MM/DD. Or appeal denial.' },
{ id: 'utility-dispute', label: 'Utility — dispute bill', icon: '⚡',
needsAccount: true, defaultGoal: 'Dispute bill amount $X for service period MM/DD-MM/DD. Reason: ___.' },
{ id: 'warranty-claim', label: 'Warranty — claim / repair', icon: '🔧',
needsAccount: false, defaultGoal: 'File a warranty claim on PRODUCT, model #, serial #, purchased MM/DD. Issue: ___.' },
{ id: 'mortgage-loan', label: 'Mortgage / loan — payoff / hardship',icon: '🏦',
needsAccount: true, defaultGoal: 'Request payoff statement OR hardship-modification options on loan # ___.' },
{ id: 'subscription-cancel', label: 'Subscription — cancel / refund', icon: '🔁',
needsAccount: false, defaultGoal: 'Cancel SERVICE subscription effective MM/DD. Refund last charge if eligible.' },
{ id: 'other', label: 'Something else', icon: '☎️',
needsAccount: false, defaultGoal: '' },
];
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
// 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;
return '+' + digits; // intl / short-code / any other format — let Twilio judge
}
function sanitizeRow(body) {
// Whitelist — only accept fields we explicitly know about. Prevents
// mass-assignment of e.g. is_admin / status / cost.
const cat = categoryById(body.category);
const business_phone = validatePhone(body.business_phone);
const callback_phone = validatePhone(body.callback_phone);
return {
id: newId(),
created_at: new Date().toISOString(),
status: 'queued', // queued | dialing | on_hold | connected | done | failed | cancelled
category: cat ? cat.id : 'other',
category_label: cat ? cat.label : 'Something else',
business_name: String(body.business_name || '').slice(0, 120),
business_phone,
callback_phone,
callback_name: String(body.callback_name || '').slice(0, 80),
callback_email: String(body.callback_email || '').slice(0, 120),
goal: String(body.goal || '').slice(0, 1500),
account_number: String(body.account_number || '').slice(0, 40),
last4_ssn: String(body.last4_ssn || '').replace(/\D/g, '').slice(0, 4),
billing_zip: String(body.billing_zip || '').replace(/\D/g, '').slice(0, 5),
date_of_birth: String(body.date_of_birth || '').slice(0, 10),
auth_password: String(body.auth_password || '').slice(0, 60),
best_time_window: String(body.best_time_window || '').slice(0, 60),
max_hold_minutes: Math.min(180, parseInt(body.max_hold_minutes, 10) || 60),
max_spend_cents: Math.min(10000, parseInt(body.max_spend_cents, 10) || 500), // $5 default cap
consent_recording: body.consent_recording === 'on' || body.consent_recording === true,
consent_terms: body.consent_terms === 'on' || body.consent_terms === true,
notify_sms: body.notify_sms === 'on' || body.notify_sms === true,
notify_email: body.notify_email === 'on' || body.notify_email === true,
notes: String(body.notes || '').slice(0, 500),
};
}
// All call-mutating + call-reading functions now require a userId so each
// user only sees their own records. user_id of null = legacy unscoped row
// (only visible to admin role via a separate path; defaults to invisible).
function addCall(body, userId) {
const errors = [];
if (!body.business_name) errors.push('business_name required');
if (!validatePhone(body.business_phone)) errors.push('business_phone is not a valid number');
if (!validatePhone(body.callback_phone)) errors.push('callback_phone is not a valid number');
if (!body.goal || String(body.goal).trim().length < 8) errors.push('goal is too short (8+ chars required)');
if (!body.consent_terms) errors.push('consent_terms must be checked');
if (!userId) errors.push('user_id required (must sign in)');
if (errors.length) return { ok: false, errors };
// ── Call-once-per-person hard guard (2026-05-13 standing rule) ─────────
// Block re-dialing a phone number that was already called by Butlr unless
// 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 prior = allPriors.find(c => !['canceled','failed'].includes(c.status));
if (prior) {
return {
ok: false,
errors: [`repeat_call_blocked: Butlr already called ${body.business_phone} on ${prior.created_at} (call ${prior.id}, status ${prior.status}). Set allow_repeat=true to override.`],
};
}
}
// ── 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 = 12;
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;
const persistedRow = fieldCrypto.encryptFields(plaintextRow, SENSITIVE_FIELDS);
persistedRow.user_id = userId; // user_id is NOT sensitive — keep plaintext
const all = readAll();
all.unshift(persistedRow);
writeAll(all);
return { ok: true, call: plaintextRow };
}
function listCalls(userId) {
// List view never decrypts — just shows masked placeholder where data exists.
// Returns only rows owned by userId. If userId is missing, returns [].
if (!userId) return [];
return readAll()
.filter(c => c.user_id === userId)
.map(c => ({
...c,
account_number: c.account_number ? '••••••••••' : '',
last4_ssn: c.last4_ssn ? '••••' : '',
auth_password: c.auth_password ? '••••••' : '',
}));
}
// getCall(id, userId, reveal)
// Returns null if call not found OR if call.user_id !== userId.
// Always 404 (no oracle) — callers should never branch on "exists but
// forbidden" because that leaks call-ID existence.
function getCall(id, userId, reveal = false) {
if (!id || !userId) return null;
const c = readAll().find(x => x.id === id);
if (!c) return null;
if (c.user_id !== userId) return null;
if (reveal) {
return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
}
const decrypted = fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
return {
...c,
account_number: maskAccount(decrypted.account_number),
last4_ssn: decrypted.last4_ssn ? '••••' : '',
auth_password: decrypted.auth_password ? '••••••' : '',
billing_zip: decrypted.billing_zip ? '•••••' : '',
date_of_birth: decrypted.date_of_birth ? '••/••/••••' : '',
};
}
// Internal-only: list ALL calls (across users) for the background worker.
// Never expose this through a user-facing route.
function listCallsUnscoped() {
return readAll().map(c => ({
...c,
account_number: c.account_number ? '••••••••••' : '',
last4_ssn: c.last4_ssn ? '••••' : '',
auth_password: c.auth_password ? '••••••' : '',
}));
}
// Internal-only: get a call WITHOUT user scoping. Used by Twilio webhooks
// (which present a callId from a Twilio-controlled URL, not a user cookie)
// and the call worker (background Twilio dialer). Never call this from a
// user-facing route — it bypasses ownership checks.
function getCallUnscoped(id, reveal = false) {
if (!id) return null;
const c = readAll().find(x => x.id === id);
if (!c) return null;
if (reveal) return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
const decrypted = fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
return {
...c,
account_number: maskAccount(decrypted.account_number),
last4_ssn: decrypted.last4_ssn ? '••••' : '',
auth_password: decrypted.auth_password ? '••••••' : '',
billing_zip: decrypted.billing_zip ? '•••••' : '',
date_of_birth: decrypted.date_of_birth ? '••/••/••••' : '',
};
}
function updateStatus(id, status) {
const all = readAll();
const idx = all.findIndex(c => c.id === id);
if (idx < 0) return null;
all[idx].status = status;
all[idx].updated_at = new Date().toISOString();
writeAll(all);
return all[idx];
}
function patchRow(id, patch) {
const all = readAll();
const idx = all.findIndex(c => c.id === id);
if (idx < 0) return null;
all[idx] = { ...all[idx], ...patch, updated_at: new Date().toISOString() };
writeAll(all);
return all[idx];
}
function setTwilioSid(id, sid) { return patchRow(id, { twilio_call_sid: sid }); }
function setRecordingMeta(id, meta) { return patchRow(id, meta); }
function setTranscriptMeta(id, meta) { return patchRow(id, meta); }
// ── Preset business directory ─────────────────────────────────────────
let _businesses = null;
function loadBusinesses() {
if (_businesses) return _businesses;
try { _businesses = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'businesses.json'), 'utf8')); }
catch (e) { _businesses = []; }
return _businesses;
}
function businessBySlug(slug) {
return loadBusinesses().find(b => b.slug === slug) || null;
}
function businessesForCategory(catId) {
if (!catId) return loadBusinesses();
return loadBusinesses().filter(b => (b.category_hints || []).includes(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,
};