← back to NationalPaperHangers

routes/call-installer.js

119 lines

// "Call this installer" — places a Butlr call on the customer's behalf.
//
// The customer taps "Call this installer" on an installer profile, picks one
// of three modes in a modal, and this endpoint hands the call to Butlr's
// external API (lib/butlr.js → POST /api/external/place-call).
//
// COMPLIANCE: the customer explicitly initiates the call to a business they
// chose to view — that tap is the consent. Nothing here auto-dials. The
// AI-agent mode identifies itself per FCC norms; that disclosure lives in
// Butlr's announcement preamble (lib/twilio.js buildAnnouncement).
//
// DATA SEPARATION: NPH only reads the installer's phone from its own DB and
// POSTs it to Butlr. It never writes to Butlr's store.

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

const MODES = new Set(['hold', 'bridge', 'ai_agent']);

// Loose E.164-ish validation — strip non-digits, require 10-15 digits.
// Butlr does its own canonicalization; this is just a fast reject.
function normalizePhone(raw) {
  const digits = String(raw || '').replace(/\D/g, '');
  if (digits.length < 10 || digits.length > 15) return null;
  if (digits.length === 10) return '+1' + digits;
  if (digits.length === 11 && digits.startsWith('1')) return '+' + digits;
  return '+' + digits;
}

// POST /installer/:slug/call-installer
// Body: { mode, customer_phone, customer_name?, brief? }
// Responds JSON — the profile-page modal renders the result inline.
router.post('/installer/:slug/call-installer', express.urlencoded({ extended: false }), async (req, res) => {
  try {
    if (!butlr.isConfigured()) {
      return res.status(503).json({ ok: false, error: 'unavailable',
        message: 'The call service is not available right now. Please use the contact form.' });
    }

    const installer = await db.one(
      `SELECT id, slug, business_name, phone, claim_status, status
         FROM installers
        WHERE slug = $1 AND (status = 'active' OR claim_status = 'unclaimed')`,
      [req.params.slug]
    );
    if (!installer) {
      return res.status(404).json({ ok: false, error: 'not_found', message: 'Installer not found.' });
    }

    const installerPhone = normalizePhone(installer.phone);
    if (!installerPhone) {
      return res.status(422).json({ ok: false, error: 'no_phone',
        message: 'This installer has no phone number on file. Please use the contact form.' });
    }

    const f = req.body || {};
    const mode = String(f.mode || '').trim();
    const customerPhone = normalizePhone(f.customer_phone);
    const customerName = String(f.customer_name || '').trim().slice(0, 80) || 'A potential client';
    const brief = String(f.brief || '').trim().slice(0, 1500);

    const errors = [];
    if (!MODES.has(mode)) errors.push('Pick how you\'d like the call placed.');
    if (!customerPhone) errors.push('Enter a valid phone number we can reach you on.');
    if (mode === 'ai_agent' && brief.length < 8) {
      errors.push('Describe your project (at least a sentence) so the AI agent has something to say.');
    }
    if (errors.length) {
      return res.status(400).json({ ok: false, error: 'validation', errors });
    }

    const result = await butlr.placeCall({
      mode,
      installerPhone,
      installerName: installer.business_name,
      customerPhone,
      customerName,
      brief,
    });

    if (!result.ok) {
      // Butlr's 4-call-per-number hard cap surfaces as HTTP 429.
      if (result.status === 429) {
        return res.status(429).json({ ok: false, error: 'too_many_calls',
          message: `${installer.business_name} has already been called the maximum number of times today. Please use the contact form or try again later.` });
      }
      const msg = (result.body && (result.body.message
        || (result.body.errors && result.body.errors.join('; '))))
        || result.message
        || 'The call could not be placed. Please try again or use the contact form.';
      const code = result.status && result.status >= 400 && result.status < 500 ? result.status : 502;
      return res.status(code).json({ ok: false, error: result.error || 'butlr_error', message: msg });
    }

    const dryRun = !!(result.body && result.body.dry_run);
    const modeLabels = {
      hold: 'wait through the hold queue and call you back when a person answers',
      bridge: 'connect you both on a live call',
      ai_agent: 'have an AI agent deliver your project brief and report back',
    };
    res.json({
      ok: true,
      mode,
      dry_run: dryRun,
      message: dryRun
        ? `Test mode: the call was queued but no real phone call was placed. (Butlr is in dry-run; real dialing is disabled.) When live, Butlr would ${modeLabels[mode]}.`
        : `Done — Butlr will ${modeLabels[mode]}. You'll get a text update shortly.`,
    });
  } catch (err) {
    console.error('[call-installer]', err && err.stack ? err.stack : err);
    res.status(500).json({ ok: false, error: 'server_error',
      message: 'Something went wrong placing the call. Please try again.' });
  }
});

module.exports = router;