← back to Professional Directory

agents/api-agent/routes/admin-pitch.js

130 lines

/**
 * /admin/pitch — pitch funnel admin tools.
 *
 *   GET  /admin/pitch/queue              — unclaimed orgs ranked by lead score
 *   GET  /admin/pitch/:orgId/preview     — bundle of mockup URLs + email recipients
 *   POST /admin/pitch/:orgId/send        — fire pitch email via George gmail
 *
 * The "3 variant" pitch: each org already has 1 generated mockup at
 * data/mockups/<id>.html (template-rotation). The pitch email links to:
 *   - /preview/:orgId   (the basic /preview from pd-api)
 *   - /mockups/:orgId   (the bespoke template-rotated mockup)
 *   - the org's CURRENT website (if one exists)
 * That's 3 concrete options for the prospect to compare.
 */
const express = require('express');
const fs = require('fs');
const path = require('path');
const { fetch } = require('undici');
const { query } = require('../../shared/db');
const { requireRole } = require('../auth');

const router = express.Router();
router.use(requireRole('admin'));

const GEORGE_BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
const PUBLIC_BASE = process.env.PD_PUBLIC_BASE || 'http://127.0.0.1:9876';
const MOCKUPS_DIR = path.resolve(__dirname, '../../../data/mockups');

router.get('/queue', async (req, res, next) => {
  try {
    const limit = Math.min(Number(req.query.limit) || 50, 200);
    const r = await query(`
      SELECT o.id, o.name, o.type, o.city, o.zip, o.website, o.lead_score,
             o.contacted_at, o.lat, o.lng,
             (SELECT COUNT(*) FROM emails e WHERE e.organization_id = o.id) AS emails,
             (SELECT email FROM emails e WHERE e.organization_id = o.id ORDER BY id LIMIT 1) AS primary_email
        FROM organizations o
       WHERE o.opted_out = false
         AND o.claim_status = 'unclaimed'
         AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)
       ORDER BY (o.contacted_at IS NULL) DESC, o.lead_score DESC NULLS LAST
       LIMIT ${limit}
    `);
    // Annotate with mockup availability.
    const rows = r.rows.map(o => ({
      ...o,
      has_mockup: fs.existsSync(path.join(MOCKUPS_DIR, `${o.id}.html`)),
    }));
    res.json({ count: rows.length, rows });
  } catch (e) { next(e); }
});

router.get('/:orgId/preview', async (req, res, next) => {
  try {
    const id = Number(req.params.orgId);
    const o = (await query(`SELECT * FROM organizations WHERE id=$1`, [id])).rows[0];
    if (!o) return res.status(404).json({ error: 'not found' });
    const emails = (await query(`SELECT email, email_type FROM emails WHERE organization_id=$1`, [id])).rows;
    const hasMockup = fs.existsSync(path.join(MOCKUPS_DIR, `${id}.html`));
    res.json({
      org: o,
      emails,
      pitch_assets: {
        preview_url: `${PUBLIC_BASE.replace(':9876', ':9874')}/preview/${id}`,
        mockup_url:  hasMockup ? `${PUBLIC_BASE.replace(':9876', ':9874')}/mockups/${id}.html` : null,
        public_org_page: `${PUBLIC_BASE}/orgs/${id}`,
        current_website: o.website || null,
      },
    });
  } catch (e) { next(e); }
});

router.post('/:orgId/send', async (req, res, next) => {
  try {
    const id = Number(req.params.orgId);
    const toEmail = String(req.body?.to || '').trim();
    if (!toEmail || !/.+@.+/.test(toEmail)) return res.status(400).json({ error: 'to (email) required' });
    const o = (await query(`SELECT id, name, city, type, website FROM organizations WHERE id=$1`, [id])).rows[0];
    if (!o) return res.status(404).json({ error: 'org not found' });
    const hasMockup = fs.existsSync(path.join(MOCKUPS_DIR, `${id}.html`));

    // Pitch assets — admin can override these in the body if needed.
    const previewUrl = req.body?.preview_url || `${PUBLIC_BASE.replace(':9876', ':9874')}/preview/${id}`;
    const mockupUrl  = req.body?.mockup_url  || (hasMockup ? `${PUBLIC_BASE.replace(':9876', ':9874')}/mockups/${id}.html` : null);
    const orgPageUrl = req.body?.org_url     || `${PUBLIC_BASE}/orgs/${id}`;

    const subject = req.body?.subject || `A modern site for ${o.name} (drafted, free to look)`;

    const links = [
      mockupUrl  ? `   1. The bespoke draft (built just for ${o.name}):\n      ${mockupUrl}` : null,
      previewUrl ? `   2. A clean editorial revamp of your existing data:\n      ${previewUrl}` : null,
      o.website  ? `   3. Compared to your current site:\n      ${o.website}` : null,
    ].filter(Boolean).join('\n\n');

    const body = req.body?.body || `Hi —

I'm Steve Abrams, an LA entrepreneur and small-business owner running Agent Abrams. I built three website concepts for ${o.name}${o.city ? ' (' + o.city + ')' : ''} that you can look at right now — no chat, no demo, no commitment:

${links}

Everything on the drafts is your real data — address, phone, services. The look and copy is mine to revise. If any direction feels right, reply with a yes and I put it live within a week. If none of them work, no harm done — close the tab.

— Steve Abrams
   steve@designerwallcoverings.com
   Agent Abrams · LA entrepreneur and small business owner`;

    // Send via George Gmail. Disable in tests by setting GEORGE_DISABLE=1.
    let sent = false, georgeErr = null;
    if (!process.env.GEORGE_DISABLE) {
      try {
        const r = await fetch(`${GEORGE_BASE}/api/send`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ to: toEmail, subject, body }),
          signal: AbortSignal.timeout(20_000),
        });
        sent = r.ok;
        if (!r.ok) georgeErr = `george http ${r.status}`;
      } catch (e) { georgeErr = e.message; }
    } else { sent = true; georgeErr = '(GEORGE_DISABLE=1, no email sent)'; }

    // Record contact regardless of George success — admin can re-send later.
    await query(`UPDATE organizations SET contacted_at = COALESCE(contacted_at, now()), updated_at = now() WHERE id = $1`, [id]);

    res.json({ ok: true, sent, to: toEmail, mockup_used: !!mockupUrl, george_status: georgeErr || 'sent' });
  } catch (e) { next(e); }
});

module.exports = router;