← 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,
};