← back to Butlr

routes/public.js

533 lines

const express = require('express');
const router = express.Router();
const data = require('../lib/data');

router.use((req, res, next) => {
  res.locals.site_title = 'Butlr';
  res.locals.path = req.path;
  next();
});

router.get('/', (req, res) => {
  res.render('public/home', {
    title: 'Butlr — We wait on hold so you don\'t have to',
    meta_desc: 'Skip the hold music. Submit who to call and what you need done; we wait, you live your life. Credit cards, hospitals, restaurants, anywhere there\'s a queue.',
  });
});

// ── 4-step wizard ─────────────────────────────────────────────────────
// State carried via hidden inputs (no cookies, no session — works behind
// any rate-limiter, survives back/forward). Each step renders the next.
//
// Step 1: /new                  — pick category
// Step 2: /new/business         — pick from preset directory OR custom
// Step 3: /new/details          — goal + account info
// Step 4: /new/callback         — callback + limits + consent → submits

function pickState(b) {
  // Whitelist what carries between steps. Same fields as sanitizeRow but
  // without the generated id/status — we accumulate during the wizard.
  return {
    category:           b.category || '',
    business_slug:      b.business_slug || '',
    business_name:      b.business_name || '',
    business_phone:     b.business_phone || '',
    goal:               b.goal || '',
    notes:              b.notes || '',
    account_number:     b.account_number || '',
    last4_ssn:          b.last4_ssn || '',
    billing_zip:        b.billing_zip || '',
    date_of_birth:      b.date_of_birth || '',
    auth_password:      b.auth_password || '',
    callback_name:      b.callback_name || '',
    callback_phone:     b.callback_phone || '',
    callback_email:     b.callback_email || '',
    best_time_window:   b.best_time_window || '',
    max_hold_minutes:   b.max_hold_minutes || '60',
    max_spend_cents:    b.max_spend_cents || '500',
    consent_recording:  b.consent_recording === 'on' || b.consent_recording === true,
    notify_sms:         b.notify_sms === 'on' || b.notify_sms === true || !b.notify_sms,  // default on
    notify_email:       b.notify_email === 'on' || b.notify_email === true,
    consent_terms:      b.consent_terms === 'on' || b.consent_terms === true,
  };
}

// Step 1 — Category
router.get('/new', (req, res) => {
  const state = pickState(req.query);
  if (req.query.category) state.category = req.query.category;
  res.render('public/wizard-1-category', {
    title: 'Start a call — Step 1 of 4 — Butlr',
    meta_desc: 'Step 1: pick the kind of call.',
    categories: data.CATEGORIES,
    state, step: 1,
  });
});

// Step 2 — Business (preset picker or custom)
router.post('/new/business', (req, res) => {
  const state = pickState(req.body);
  if (!state.category) {
    return res.redirect('/new');
  }
  const businesses = data.businessesForCategory(state.category);
  res.render('public/wizard-2-business', {
    title: 'Start a call — Step 2 of 4 — Butlr',
    meta_desc: 'Step 2: who to call.',
    categories: data.CATEGORIES,
    businesses,
    state, step: 2,
    preset: data.categoryById(state.category),
  });
});

// Step 3 — Details (goal + account info)
router.post('/new/details', (req, res) => {
  const state = pickState(req.body);
  if (!state.category) return res.redirect('/new');

  // If they picked a preset, hydrate name + phone from the directory
  if (state.business_slug && state.business_slug !== '__custom__') {
    const biz = data.businessBySlug(state.business_slug);
    if (biz) {
      state.business_name = biz.name;
      state.business_phone = biz.phone;
    }
  }
  // Validate at least one of preset-picked OR custom-filled
  if (!state.business_name || !state.business_phone) {
    const businesses = data.businessesForCategory(state.category);
    return res.render('public/wizard-2-business', {
      title: 'Start a call — Step 2 of 4 — Butlr',
      meta_desc: 'Step 2: who to call.',
      categories: data.CATEGORIES,
      businesses,
      state, step: 2,
      preset: data.categoryById(state.category),
      errors: ['Pick a preset or enter both a business name and phone number.'],
    });
  }

  res.render('public/wizard-3-details', {
    title: 'Start a call — Step 3 of 4 — Butlr',
    meta_desc: 'Step 3: what you need done.',
    state, step: 3,
    preset: data.categoryById(state.category),
  });
});

// Step 4 — Callback + limits + consent (final, then submits)
router.post('/new/callback', (req, res) => {
  const state = pickState(req.body);
  if (!state.category) return res.redirect('/new');
  if (!state.goal || state.goal.trim().length < 8) {
    return res.render('public/wizard-3-details', {
      title: 'Start a call — Step 3 of 4 — Butlr',
      meta_desc: 'Step 3: what you need done.',
      state, step: 3,
      preset: data.categoryById(state.category),
      errors: ['Goal is required (8+ chars). Be specific about what you want the rep to do.'],
    });
  }
  res.render('public/wizard-4-callback', {
    title: 'Start a call — Step 4 of 4 — Butlr',
    meta_desc: 'Step 4: how to reach you back.',
    state, step: 4,
  });
});

// Final submit — IMMEDIATE dial (don't wait for the 5s worker tick).
// Requires sign-in; redirects through /signup if no user.
router.post('/new/submit', async (req, res) => {
  if (!req.user) {
    // Stash the form payload in session-cookie-equivalent so signup→post creates it.
    // For v1 simplicity, redirect to /signup with return URL = /new (lose draft).
    return res.redirect(302, '/signup?return=' + encodeURIComponent('/new'));
  }
  // 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;
  // If user picked schedule-for-later OR recurring, route to scheduling
  // instead of an immediate dial. Same gating (auth, validation) applies.
  const when_mode = String(body.when_mode || 'now').toLowerCase();
  if (when_mode === 'later' || when_mode === 'recurring') {
    try {
      const scheduling = require('../lib/scheduling');
      const row = scheduling.addScheduled({
        user_id: req.user.id,
        business_name:  body.business_name,
        business_phone: body.business_phone,
        goal:           body.goal,
        callback_phone: body.callback_phone,
        callback_name:  body.callback_name,
        category:       body.category,
        schedule_at:    when_mode === 'later'     ? new Date(body.schedule_at).toISOString() : null,
        cron_expr:      when_mode === 'recurring' ? body.cron_expr : null,
        consent_recording: body.consent_recording === 'on' || body.consent_recording === true,
      });
      return res.redirect('/calendar');
    } catch (e) {
      const state = pickState(req.body);
      return res.status(400).render('public/wizard-4-callback', {
        title: 'Start a call — Step 4 of 4 — Butlr',
        meta_desc: 'Step 4: how to reach you back.',
        state, step: 4,
        errors: ['Schedule error: ' + e.message],
      });
    }
  }
  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', {
      title: 'Start a call — Step 4 of 4 — Butlr',
      meta_desc: 'Step 4: how to reach you back.',
      state, step: 4,
      errors: r.errors,
    });
  }
  // Kick the dial in-process so the user lands on /live with the call already moving.
  // Errors are non-fatal; the 5s worker tick will catch any that fail here.
  try {
    const twilio = require('../lib/twilio');
    twilio.dialCall(r.call).catch(e => console.error('[immediate-dial]', e.message));
  } catch (e) { console.error('[immediate-dial-spawn]', e.message); }
  res.redirect('/calls/' + r.call.id + '/live');
});

// Quick-dial: minimal-fields POST that places a call without the 4-step wizard.
// Used by the form at the top of /calls. Auth-gated (mounted under /api/calls
// prefix → requireOwner). Reuses the immediate-dial path so the worker tick
// doesn't have to be the one to pick it up.
// Client-side DNC pre-check — /new wizard hits this on phone-blur so the
// user sees "this number is blocked" BEFORE submitting the form. Auth-
// gated. Does NOT log a block (no dial attempt happened); the block log
// is only written on actual dial attempts inside tick().
router.get('/api/dnc-check', (req, res) => {
  if (!req.user) return res.status(401).json({ ok: false, error: 'sign in first' });
  const phone = String(req.query.phone || '').trim();
  if (!phone) return res.status(400).json({ ok: false, error: 'phone required' });
  try {
    const dnc = require('../lib/dnc-check');
    const result = dnc.checkPhone(phone, 'precheck');
    // Don't leak the underlying source detail to non-admins; just allowed/reason.
    res.json({
      ok: true,
      allowed: result.allowed,
      reason: result.reason || null,
      dry_run_bypass: !!result.dry_run_bypass,
    });
  } catch (e) {
    // Gate-error (missing snapshot) → block + surface error code so UI
    // can render "DNC list unavailable; we won't dial this until it's restored"
    res.json({ ok: true, allowed: false, reason: 'gate_error', code: e.code || 'unknown' });
  }
});

router.post('/api/quick-dial', express.urlencoded({ extended: true }), async (req, res) => {
  if (!req.user) return res.status(401).json({ ok: false, error: 'sign in first' });
  const body = req.body || {};
  // Apply sane defaults so addCall validation passes.
  const callRow = {
    business_name: body.business_name,
    business_phone: body.business_phone,
    goal: body.goal,
    // callback_phone is the number Twilio dials for Bridge-me-now — should
    // be the human's personal cell, NOT the Twilio outbound FROM line.
    // Body override > user record > BUTLR_OWNER_CALLBACK_PHONE env > Twilio
    // FROM (last-resort, will mean Bridge-me-now dials the Twilio line).
    callback_phone: (body.callback_phone || '').trim()
      || req.user.callback_phone
      || process.env.BUTLR_OWNER_CALLBACK_PHONE
      || process.env.TWILIO_PHONE_NUMBER
      || '+16067130489',
    callback_name: (req.user.email || '').split('@')[0] || 'You',
    callback_email: req.user.email || '',
    category: body.category || 'other',
    category_label: 'Quick dial',
    max_hold_minutes: 2,
    max_spend_cents: 50,
    consent_recording: 'on',
    consent_terms: 'on',
    notify_sms: '',
    notify_email: '',
    notes: 'placed via /calls quick-dial',
    // Pass through caller-supplied allow_repeat (string 'true' or 'on' is coerced)
    // so quick-dial can override the once-per-phone guard when the caller has
    // an explicit user-authorized retry intent.
    allow_repeat: (body.allow_repeat === 'true' || body.allow_repeat === 'on' || body.allow_repeat === true),
  };
  const r = data.addCall(callRow, req.user.id);
  if (!r.ok) return res.status(400).json({ ok: false, error: (r.errors || []).join('; ') });
  try {
    const twilio = require('../lib/twilio');
    twilio.dialCall(r.call).catch(e => console.error('[quick-dial]', e.message));
  } catch (e) { console.error('[quick-dial-spawn]', e.message); }
  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;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
  const c = data.getCall(id, req.user && req.user.id, false);
  if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
  res.render('public/call-live', {
    title: c.business_name + ' — live call — Butlr',
    meta_desc: 'Live call status with AI assistant.',
    call: c,
  });
});

// JSON status endpoint for live polling
router.get('/api/calls/:id/status', (req, res) => {
  const id = req.params.id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
  const c = data.getCall(id, req.user && req.user.id, false);
  if (!c) return res.status(404).json({ ok: false });
  res.json({
    ok: true,
    id: c.id,
    status: c.status,
    business_name: c.business_name,
    business_phone: c.business_phone,
    callback_phone: c.callback_phone,
    goal: c.goal,
    created_at: c.created_at,
    updated_at: c.updated_at || c.created_at,
  });
});

// AI chat endpoint — local Ollama (qwen3:14b per Steve's no-Anthropic standing rule)
router.post('/api/calls/:id/chat', express.json(), async (req, res) => {
  const id = req.params.id;
  if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
  const c = data.getCall(id, req.user && req.user.id, false);
  if (!c) return res.status(404).json({ ok: false });
  const userMsg = String((req.body && req.body.message) || '').slice(0, 1000).trim();
  if (!userMsg) return res.status(400).json({ ok: false, error: 'empty message' });

  const systemPrompt = `You are Butlr, an AI concierge whose job is to wait on hold for the user while their call to ${c.business_name} progresses. The user submitted this goal: "${c.goal}". The current call status is "${c.status}". Be brief (2-3 sentences max), helpful, conversational. If the user wants to tell you something to say to the rep when they pick up, write it down. If the user asks for an update, restate the current status plainly. Never pretend the call has connected when it hasn't. Never invent details about the call. You are NOT the rep — the rep hasn't picked up yet (unless status is "connected" or "done"). Stay in this persona.`;

  try {
    const oResp = await fetch('http://127.0.0.1:11434/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: 'qwen3:14b',
        stream: false,
        messages: [
          { role: 'system', content: systemPrompt },
          { role: 'user', content: userMsg },
        ],
        options: { temperature: 0.4, num_predict: 200 },
      }),
    });
    if (!oResp.ok) throw new Error('ollama returned HTTP ' + oResp.status);
    const j = await oResp.json();
    const reply = (j.message && j.message.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
    res.json({ ok: true, reply });
  } catch (e) {
    res.json({ ok: true, reply: `(AI offline — Ollama error: ${e.message}). Your call to ${c.business_name} is currently ${c.status}; we'll text you when an agent picks up.` });
  }
});

router.get('/calls', (req, res) => {
  res.render('public/calls', {
    title: 'Your call queue — Butlr',
    meta_desc: 'Calls you\'ve queued up.',
    calls: data.listCalls(req.user && req.user.id),
  });
});

router.get('/calls/:id', (req, res) => {
  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' });
  // SECURITY: reveal=1 is gated by owner-auth middleware (applied in server.js).
  // If we got here, the request is owner-authenticated, so honor reveal=1.
  // Unauthenticated requests are blocked upstream and never reach this handler.
  const wantReveal = req.query.reveal === '1' && req.ownerAuthed === true;
  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,
  });
});

router.get('/api/category/:id', (req, res) => {
  const c = data.categoryById(req.params.id);
  if (!c) return res.status(404).json({ ok: false });
  res.json({ ok: true, category: c });
});

router.get('/how-it-works', (req, res) => {
  res.render('public/how', { title: 'How it works — Butlr', meta_desc: 'How Butlr waits on hold for you.' });
});

// ── Legal pages ─────────────────────────────────────────────────────
router.get('/privacy', (req, res) => {
  res.render('public/privacy', {
    title: 'Privacy Policy — Butlr',
    meta_desc: 'How Butlr handles your account information, call data, and personal info. No sale. Encryption at rest. Deletion on request.',
    updated: '2026-05-12',
  });
});
router.get('/terms', (req, res) => {
  res.render('public/terms', {
    title: 'Terms of Service — Butlr',
    meta_desc: 'Terms governing your use of Butlr.',
    updated: '2026-05-12',
  });
});
router.get('/accessibility', (req, res) => {
  res.render('public/accessibility', {
    title: 'Accessibility — Butlr',
    meta_desc: 'Our WCAG 2.1 AA accessibility commitments, known gaps, and how to report a barrier.',
    updated: '2026-05-12',
  });
});

// ── Pricing + checkout stub (Stripe-shaped, dry-run by default) ─────
const PLANS = [
  { id: 'free', name: 'Free', price: '$0', period: '/forever',
    tagline: 'Try it. One call a month, max 30-minute hold.',
    cta: 'Get started',
    features: ['1 call per month', 'Max 30-min hold', 'SMS notification when picked up', 'Encryption at rest', 'Standard queue priority'] },
  { id: 'pro', name: 'Pro', price: '$9', period: '/mo', featured: true,
    tagline: 'For the household + small-business buyer.',
    cta: 'Start Pro',
    features: ['10 calls per month', 'Max 90-min hold each', 'SMS + email summary', 'Encryption at rest', 'Saved-account presets', 'Faster queue priority'] },
  { id: 'concierge', name: 'Concierge', price: '$29', period: '/mo',
    tagline: 'AI voice agent for low-stakes calls + unlimited queue.',
    cta: 'Start Concierge',
    features: ['Unlimited calls', 'Max 3-hr hold', 'AI voice agent (opt-in per call)', 'Full transcripts', 'Encryption at rest', 'Priority queue', 'Phone support'] },
];

router.get('/pricing', (req, res) => {
  res.render('public/pricing', {
    title: 'Pricing — Butlr',
    meta_desc: 'Free, Pro $9/mo, Concierge $29/mo. All plans include encryption at rest, TCPA-compliant dialing, and CCPA deletion-on-request.',
    plans: PLANS,
  });
});

router.post('/checkout', (req, res) => {
  const planId = String(req.body.plan || '').slice(0, 20);
  const plan = PLANS.find(p => p.id === planId);
  if (!plan) return res.status(400).render('public/error', { title: 'Invalid plan — Butlr', message: 'That plan does not exist.' });

  if (plan.id === 'free') {
    // Free tier: just redirect to /new (no checkout needed)
    return res.redirect('/new');
  }

  const stripeDryRun = process.env.STRIPE_DRY_RUN !== '0';
  if (stripeDryRun) {
    console.log(`[stripe] DRY-RUN  plan=${plan.id} price=${plan.price} — would create Checkout Session via stripe.checkout.sessions.create()`);
    return res.render('public/checkout-stub', {
      title: 'Checkout — Butlr',
      meta_desc: 'Phase 1 checkout stub.',
      plan,
    });
  }

  // LIVE — would call Stripe REST API to create a Checkout Session and redirect
  res.status(501).render('public/error', { title: 'Coming soon', message: 'Live billing not yet wired. Set STRIPE_DRY_RUN=0 + STRIPE_SECRET_KEY + STRIPE_PRICE_<plan> when ready.' });
});

// ── Internal admin: social-claim sheet ──────────────────────────────
// Pass ?name=<brand> to generate direct-signup URLs for every major social.
router.get('/social-claim', (req, res) => {
  const name = String(req.query.name || '').replace(/[^a-zA-Z0-9_]/g, '').slice(0, 30).toLowerCase();
  res.render('public/social-claim', {
    title: name ? `Claim sheet for @${name} — Butlr` : 'Social-Claim Sheet — Butlr',
    meta_desc: 'Generate direct-signup URLs for every major social platform when you lock a brand name.',
    name,
  });
});

router.get('/healthz', (req, res) => res.type('text').send('ok'));

// Owner-only runtime-config probe — used by the /butlr Claude Code skill
// to detect DRY_RUN mode before claiming "DIALED". Surfaces nothing
// sensitive (no creds, no token last4) — only the boolean flags that
// determine whether a real Twilio dial will actually happen.
router.get('/api/runtime-config', (req, res) => {
  if (!req.user) return res.status(401).json({ ok: false, error: 'unauthorized' });
  res.json({
    ok: true,
    twilio_dry_run: process.env.TWILIO_DRY_RUN !== '0',
    twilio_configured: !!(process.env.TWILIO_ACCOUNT_SID &&
      (process.env.TWILIO_AUTH_TOKEN ||
        (process.env.TWILIO_API_KEY_SID && process.env.TWILIO_API_KEY_SECRET))),
    twilio_from_set: !!process.env.TWILIO_PHONE_NUMBER,
    stripe_dry_run: process.env.STRIPE_DRY_RUN !== '0',
  });
});

module.exports = router;