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