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