← back to Butlr

routes/external.js

216 lines

// External call-trigger API — lets trusted first-party sites (currently
// NationalPaperHangers.com) place a Butlr call without going through the
// owner-authed 4-step wizard.
//
// SECURITY MODEL
//   This router is mounted OUTSIDE the requireOwner gate. It does NOT rely
//   on a user cookie. Instead every request must carry a shared secret in
//   the `X-Butlr-External-Secret` header. The secret is compared in
//   constant time against process.env.BUTLR_EXTERNAL_SECRET.
//   If BUTLR_EXTERNAL_SECRET is unset, the endpoint is hard-disabled (503)
//   — fail-closed, never open.
//
// DATA SEPARATION
//   NPH never touches Butlr's data files and vice-versa. The only contract
//   is this HTTP endpoint. Calls placed here are owned by a dedicated
//   service user ("external-service") so they don't pollute a human's queue
//   and so getCall()'s per-user scoping still holds.
//
// THREE MODES (must match the NPH modal)
//   hold      — Butlr dials the business, waits through IVR/hold, calls the
//               CUSTOMER back when a human answers. Needs customer_phone.
//   bridge    — Butlr/Twilio bridges customer <-> business directly. Needs
//               customer_phone. (Same call row shape; callback_phone is the
//               customer's number, which the live flow dials to bridge.)
//   ai_agent  — Butlr's AI calls the business, delivers a project brief,
//               reports back. Needs `brief` + customer_phone (the SMS
//               report-back contact).
//
//   customer_phone is required for every mode — hold/bridge dial it, and
//   addCall() requires a valid callback_phone on every call row regardless.
//
// CALL-ONCE RULE
//   lib/data.js addCall() HARD-blocks dialing a phone that already has a
//   non-terminal call, UNLESS allow_repeat:true. An NPH installer will
//   legitimately be called by many different customers, so every
//   NPH-originated call passes allow_repeat:true. The downstream hard cap
//   of 4 calls/number still applies (data.js MAX_CALLS_PER_PHONE) — that
//   protects the installer regardless of opt-in. See report for rationale.
//
// DRY-RUN
//   Honors TWILIO_DRY_RUN exactly like every other Butlr call path. No
//   special-casing — if TWILIO_DRY_RUN!=='0' (the default) nothing real
//   is dialed; the call row just advances through the simulated state
//   machine. The JSON response echoes `dry_run` so NPH can surface it.

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

const SECRET_HEADER = 'x-butlr-external-secret';
const SERVICE_EMAIL = 'external-service@butlr.local';

// Lazily resolve (and, first time, create) the dedicated service user that
// owns every externally-originated call. Kept out of seedLegacyAdmin so it
// only exists once an external integration is actually wired.
let _serviceUserId = null;
async function getServiceUserId() {
  if (_serviceUserId) return _serviceUserId;
  let u = users.findByEmail(SERVICE_EMAIL);
  if (!u) {
    // Unguessable random password — this account never logs in interactively.
    const r = await users.createUser({
      email: SERVICE_EMAIL,
      password: crypto.randomBytes(24).toString('base64url'),
      role: 'user',
    });
    if (!r.ok) throw new Error('failed to create service user: ' + r.error);
    u = users.findByEmail(SERVICE_EMAIL);
  }
  _serviceUserId = u.id;
  return _serviceUserId;
}

// Constant-time secret check. Fail-closed when the env secret is unset.
function checkSecret(req) {
  const expected = process.env.BUTLR_EXTERNAL_SECRET || '';
  if (!expected) return { ok: false, code: 503, error: 'external_api_disabled' };
  const supplied = String(req.headers[SECRET_HEADER] || '');
  if (!supplied) return { ok: false, code: 401, error: 'missing_secret' };
  const a = Buffer.from(expected);
  const b = Buffer.from(supplied);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return { ok: false, code: 401, error: 'bad_secret' };
  }
  return { ok: true };
}

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

router.post('/api/external/place-call', express.json(), async (req, res) => {
  const gate = checkSecret(req);
  if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });

  const b = req.body || {};
  const mode = String(b.mode || '').trim();
  const installerPhone = String(b.installer_phone || '').trim();
  const installerName = String(b.installer_name || 'this business').trim().slice(0, 120);
  const customerPhone = String(b.customer_phone || '').trim();
  const customerName = String(b.customer_name || 'A customer').trim().slice(0, 80);
  const brief = String(b.brief || '').trim().slice(0, 1500);

  const errors = [];
  if (!MODES.has(mode)) errors.push('mode must be one of: hold, bridge, ai_agent');
  if (!installerPhone) errors.push('installer_phone is required');
  // customer_phone is required for ALL modes: hold + bridge dial it directly,
  // ai_agent needs it for the SMS report-back AND because Butlr's addCall()
  // requires a valid callback_phone on every row.
  if (!customerPhone) {
    errors.push('customer_phone is required (where to call you back / report to)');
  }
  if (mode === 'ai_agent' && brief.length < 8) {
    errors.push('brief (8+ chars) is required for ai_agent mode');
  }
  if (errors.length) return res.status(400).json({ ok: false, errors });

  // Build the goal string per mode. addCall() requires goal >= 8 chars.
  let goal;
  if (mode === 'ai_agent') {
    goal = `AI agent call on behalf of ${customerName}. Project brief: ${brief} `
      + `Ask about availability and a rough timeline, then end the call and report back.`;
  } else if (mode === 'bridge') {
    goal = `Click-to-call bridge: connect ${customerName} directly with ${installerName}. `
      + `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
  } else {
    goal = `Hold-for-me: wait through IVR/hold at ${installerName} and call `
      + `${customerName} back when a live person answers. `
      + `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
  }

  // callback_phone is the number Butlr reaches the customer on — the
  // call-back target for hold, the bridge leg for bridge, the SMS
  // report-back number for ai_agent. Always the customer's phone.
  const callbackPhone = customerPhone;

  let userId;
  try {
    userId = await getServiceUserId();
  } catch (e) {
    console.error('[external] service user error:', e.message);
    return res.status(500).json({ ok: false, error: 'service_user_unavailable' });
  }

  const callRow = {
    category: 'other',
    business_name: installerName,
    business_phone: installerPhone,
    callback_phone: callbackPhone,
    callback_name: customerName,
    callback_email: '',
    goal,
    notes: `NPH call-installer · mode=${mode} · origin=nationalpaperhangers.com`,
    max_hold_minutes: mode === 'bridge' ? 5 : 60,
    max_spend_cents: 500,
    consent_recording: 'on',
    consent_terms: 'on',
    notify_sms: customerPhone ? 'on' : '',
    notify_email: '',
    // The installer is a business the customer chose to call; many different
    // customers will legitimately call the same installer. Bypass the
    // once-per-phone block. The 4-calls/number hard cap in data.js still
    // applies and is NOT bypassable.
    allow_repeat: true,
  };

  const r = data.addCall(callRow, userId);
  if (!r.ok) {
    // max_retries_reached (the hard cap) surfaces here — return 429 so NPH
    // can tell the customer the installer has been called too many times.
    const isCap = (r.errors || []).some(e => /max_retries_reached/.test(e));
    return res.status(isCap ? 429 : 400).json({ ok: false, errors: r.errors });
  }

  // Kick the dial in-process (same as the wizard's /new/submit) so the call
  // starts moving immediately rather than waiting for the 5s worker tick.
  try {
    const twilio = require('../lib/twilio');
    twilio.dialCall(r.call).catch(e => console.error('[external-dial]', e.message));
  } catch (e) {
    console.error('[external-dial-spawn]', e.message);
  }

  const dryRun = process.env.TWILIO_DRY_RUN !== '0';
  const publicUrl = process.env.PUBLIC_URL || '';
  res.json({
    ok: true,
    call_id: r.call.id,
    mode,
    status: r.call.status,
    dry_run: dryRun,
    // NPH can poll this to show live status. It is owner-scoped, so NPH
    // cannot read it without the service user's cookie — it is returned for
    // logging/debugging and future signed-status work, not as a live link.
    status_path: `/api/calls/${r.call.id}/status`,
    message: dryRun
      ? 'Call queued in DRY-RUN mode — no real phone call placed. Set TWILIO_DRY_RUN=0 on Butlr to dial for real.'
      : 'Call queued and dialing.',
  });
});

// Lightweight health probe so NPH can verify the integration is reachable
// and whether real dialing is enabled. Secret-gated like the main endpoint.
router.get('/api/external/health', (req, res) => {
  const gate = checkSecret(req);
  if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });
  res.json({
    ok: true,
    service: 'butlr-external',
    twilio_dry_run: process.env.TWILIO_DRY_RUN !== '0',
    modes: ['hold', 'bridge', 'ai_agent'],
  });
});

module.exports = router;