← back to Ventura Corridor

src/server/index.ts

6161 lines

/**
 * Ventura Corridor — read API + Leaflet mind-map viewer.
 * Local-only. Loopback bind. Default :9780.
 */
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool, query } from '../../db/pool.ts';

process.on('uncaughtException', (err) => {
  console.error('[ventura-corridor] uncaughtException:', err && (err.stack || err));
  process.exit(1);
});
process.on('unhandledRejection', (reason) => {
  console.error('[ventura-corridor] unhandledRejection:', reason);
  process.exit(1);
});
// Forensic logging — when a SIGINT/SIGTERM arrives, capture who/when so we can
// trace which watchdog or external process is restarting us. ppid identifies
// the killer (pm2 daemon, shell, hawk script, etc.).
const STARTED_AT = Date.now();
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGUSR2'] as const) {
  process.on(sig, () => {
    const uptimeS = ((Date.now() - STARTED_AT) / 1000).toFixed(1);
    console.error(`[ventura-corridor] [${new Date().toISOString()}] received ${sig} — pid=${process.pid} ppid=${process.ppid} uptime=${uptimeS}s — exiting cleanly`);
    process.exit(0);
  });
}

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '9780', 10);

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());

// ─── Appointments module (Smart Scheduling) ────────────────────────────
// Mounted before the admin gate + static handler so /book, /embed/...,
// /appointments/:id, and /api/appointments/* are publicly reachable and
// embeddable from third-party origins (Shopify, etc.).
// To disable the feature, delete these two lines and migration 022.
import appointmentsRouter from './appointments/router.ts';
app.use(appointmentsRouter);

// ─── Admin-gate competitive-intelligence surfaces ─────────────────────
// /signals.html, /dw-radar.html, /api/signals, /api/dw-radar are admin-only.
// Public users see the 2D map, 3D corridor, wall — those stay open.
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS;
if (!ADMIN_PASS) {
  console.error('[ventura-corridor] FATAL: ADMIN_PASS env var must be set (no source fallback).');
  console.error('[ventura-corridor] Set ADMIN_PASS in .env or via pm2 --update-env. Exiting.');
  process.exit(1);
}
// Case-insensitive · trailing slash tolerated · matches with or without query string.
const ADMIN_PATHS = [
  /^\/signals(\.html)?\/?$/i,
  /^\/dw-radar(\.html)?\/?$/i,
  /^\/api\/signals\/?$/i,
  /^\/api\/dw-radar\/?$/i,
  /^\/api\/export\.csv\/?$/i,
  /^\/api\/leaderboard\/?$/i,
  /^\/api\/pitches(\/.*)?$/i,
  /^\/pitches(\.html)?\/?$/i,
  /^\/api\/linkedin(\/.*)?$/i,
  /^\/linkedin(\.html)?\/?$/i,
  /^\/today(\.html)?\/?$/i,
  /^\/pitches-map(\.html)?\/?$/i,
  /^\/sales-nav-signup(\.html)?\/?$/i,
  /^\/walk-route(\.html)?\/?$/i,
  /^\/responses(\.html)?\/?$/i,
  /^\/api\/responses(\.csv|\/.*)?$/i,
  /^\/api\/followups\/?$/i,
  /^\/api\/snapshots\/?$/i,
  /^\/api\/activity\/?$/i,
  /^\/api\/data-freshness\/?$/i,
  /^\/admin\/upgrade-leads\/?$/i,
  /^\/api\/admin\/upgrade-leads\/?$/i,
  /^\/api\/stale-pitches\/?$/i,
  /^\/magazine(\.html)?\/?$/i,
  /^\/api\/magazine(\.csv|\/.*)?$/i,
  /^\/magazine\/\d+\/?$/i,
  /^\/magazine\/\d+\/(ad-pack|print)\.html\/?$/i,
  /^\/share\/\d+\/?$/i,
  /^\/issue(\/[\w-]+)?\/?$/i,
  /^\/issue\.pdf\/?$/i,
  /^\/issue\.epub\/?$/i,
  /^\/issue\.txt\/?$/i,
  /^\/search(\.html)?\/?$/i,
  /^\/find(\.html)?\/?$/i,
  /^\/api\/find\/?$/i,
  /^\/words\/?$/i,
  /^\/quotes\/?$/i,
  /^\/coverage(\.html)?\/?$/i,
  /^\/api\/coverage(\/.*)?$/i,
  /^\/health(\.html)?\/?$/i,
  /^\/duplicates(\.html)?\/?$/i,
  /^\/calendar(\.html)?\/?$/i,
  /^\/sitemap\.xml\/?$/i,
  /^\/robots\.txt\/?$/i,
  /^\/issue(\/[\w-]+)?\/feed\.xml\/?$/i,
  /^\/scoreboard(\.html)?\/?$/i,
  /^\/rate-card(\.html)?\/?$/i,
  /^\/sponsor(\.html)?\/?$/i,
  /^\/api\/sponsor\/inquiries\/?$/i,
  /^\/about(\.html)?\/?$/i,
  /^\/api\/feedback(\/\d+)?\/?$/i,
  /^\/feedback(\.html)?\/?$/i,
  /^\/admin\/ig-drafts(\/.*)?$/i,
  /^\/api\/issues(\/.*)?$/i,
  /^\/standup(\/.*)?$/i,
  /^\/covers(\/.*)?$/i,
  /^\/cover-picker(\.html)?\/?$/i,
  /^\/sponsor\/\d+\/?$/i,
  /^\/api\/sponsor(\/.*)?$/i,
  /^\/crawl-derby(\.html)?\/?$/i,
  /^\/api\/crawl(\/.*)?$/i,
  /^\/postcards(\.html)?\/?$/i,
  /^\/buildings(\.html)?\/?$/i,
  /^\/buildings\/[^/]+\/issue\/?$/i,
  /^\/api\/buildings(\.csv|\/.*)?$/i,
  /^\/news(\.html)?\/?$/i,
  /^\/news\/feed\.xml\/?$/i,
  /^\/news\/archive(\.html)?\/?$/i,
  /^\/business\/\d+\/news\/?$/i,
  /^\/this-week(\.html)?\/?$/i,
  /^\/api\/news(\/.*)?$/i,
  /^\/pitch\/\d+\/?$/i,
];
app.use((req, res, next) => {
  if (!ADMIN_PATHS.some((re) => re.test(req.path))) return next();
  const auth = req.headers.authorization || '';
  const m = auth.match(/^Basic\s+(.+)$/i);
  if (m) {
    const decoded = Buffer.from(m[1], 'base64').toString('utf8');
    const colonIdx = decoded.indexOf(':');
    const [u, p] = colonIdx >= 0 ? [decoded.slice(0, colonIdx), decoded.slice(colonIdx + 1)] : [decoded, ''];
    if (u === ADMIN_USER && p === ADMIN_PASS) return next();
  }
  // Defense-in-depth: setHeader can throw on non-ASCII input (caught 4 times in
  // historical pm2 logs as ERR_INVALID_CHAR → uncaughtException → process exit).
  // Wrap so a malformed auth attempt cannot crash the process.
  try {
    res.set('WWW-Authenticate', 'Basic realm="Ventura Corridor Admin", charset="UTF-8"');
    res.status(401).type('text/plain').send('Authentication required.');
  } catch {
    res.status(401).end();
  }
});

// ─── Health ─────────────────────────────────────────────────────────────
app.get('/api/health', async (_req, res) => {
  const started = Date.now();
  try {
    const r = await query(`
      SELECT
        (SELECT count(*) FROM businesses)                                               AS businesses,
        (SELECT count(*) FROM businesses WHERE on_corridor)                              AS on_corridor,
        (SELECT count(*) FROM business_enrichment)                                       AS enrichment_rows,
        (SELECT count(*) FROM front_page_audits WHERE raw_html_path IS NOT NULL)         AS crawls_with_html,
        (SELECT count(*) FROM business_enrichment WHERE ad_signals IS NOT NULL)          AS rows_with_signals,
        (SELECT count(*) FROM business_enrichment WHERE headline IS NOT NULL)            AS rows_with_headline,
        (SELECT count(*) FROM business_enrichment WHERE chamber_memberships IS NOT NULL) AS rows_with_chamber,
        (SELECT count(*) FROM business_enrichment WHERE crawl_blocked IS NOT NULL)       AS crawl_blocked,
        (SELECT count(*) FROM corridor_segments)                                         AS corridor_segments
    `);
    const counts = r.rows[0];
    const empty = !Number(counts.rows_with_signals) && !Number(counts.rows_with_headline);
    res.json({
      ok: true,
      port: PORT,
      ts: new Date().toISOString(),
      uptime_seconds: Math.round(process.uptime()),
      db_response_ms: Date.now() - started,
      counts,
      enrichment_state: empty ? 'pending' : 'populated',
      pipelines_to_run: empty ? [
        'npx tsx src/enrich/ad_signals.ts',
        'npx tsx src/enrich/extract_headlines.ts',
        'npx tsx src/enrich/score_headlines_heur.ts',
        'npx tsx src/enrich/chambers.ts',
      ] : [],
    });
  } catch (e: any) {
    res.status(500).json({ ok: false, error: e.message, ts: new Date().toISOString() });
  }
});

// ─── Stats — high-level counts for the landing ─────────────────────────
app.get('/api/stats', async (_req, res) => {
  try {
    const r = await query<{ key: string; value: string }>(`
      SELECT 'businesses_total' AS key, COUNT(*)::text AS value FROM businesses
      UNION ALL SELECT 'businesses_corridor', COUNT(*)::text FROM businesses WHERE on_corridor
      UNION ALL SELECT 'businesses_with_site', COUNT(*)::text FROM businesses WHERE on_corridor AND website IS NOT NULL
      UNION ALL SELECT 'audits_total', COUNT(*)::text FROM front_page_audits
      UNION ALL SELECT 'scored_total', COUNT(DISTINCT business_id)::text FROM seo_scores
      UNION ALL SELECT 'segments_total', COUNT(*)::text FROM corridor_segments
    `);
    const out: Record<string, number> = {};
    for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
    res.json(out);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Businesses listing — for the map ──────────────────────────────────
app.get('/api/businesses', async (req, res) => {
  try {
    const limitRaw = parseInt(String(req.query.limit ?? '5000'), 10);
    const limit = isNaN(limitRaw) || limitRaw < 0 ? 5000 : Math.min(limitRaw, 25000);
    const source = String(req.query.source ?? '');     // '', 'osm', 'la_btrc'
    const audited = String(req.query.audited ?? '');   // '', '1' (only with screenshot)
    const where = ['b.on_corridor', 'b.lat IS NOT NULL'];
    const params: any[] = [];
    if (source === 'osm' || source === 'la_btrc') {
      params.push(source);
      where.push(`b.source = $${params.length}`);
    }
    if (audited === '1') where.push(`a.screenshot_path IS NOT NULL`);
    params.push(limit);
    const r = await query(`
      SELECT b.id, b.name, b.category, b.category_naics, b.source, b.address, b.city, b.zip,
             b.lat, b.lng, b.phone, b.website, b.on_corridor, b.corridor_block,
             b.has_btrc_match, b.btrc_match_score,
             s.total AS seo_score, s.tier AS seo_tier, s.category_rank, s.category_n,
             e.ownership_class, e.parent_brand, e.is_franchise, e.is_chain,
             a.screenshot_path,
             COALESCE(n.news_count, 0)::int AS news_count
      FROM businesses b
      LEFT JOIN LATERAL (
        SELECT total, tier, category_rank, category_n
        FROM seo_scores
        WHERE business_id = b.id
        ORDER BY scored_at DESC
        LIMIT 1
      ) s ON TRUE
      LEFT JOIN business_enrichment e ON e.business_id = b.id
      LEFT JOIN LATERAL (
        SELECT screenshot_path FROM front_page_audits
        WHERE business_id = b.id AND screenshot_path IS NOT NULL AND error_message IS NULL
        ORDER BY audited_at DESC LIMIT 1
      ) a ON TRUE
      LEFT JOIN LATERAL (
        SELECT COUNT(*) AS news_count FROM news_items WHERE business_id = b.id
      ) n ON TRUE
      WHERE ${where.join(' AND ')}
      ORDER BY b.corridor_block ASC NULLS LAST, b.name ASC
      LIMIT $${params.length}
    `, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Tier upgrade — per-business pricing comparison + upgrade intent ───
// Public, no admin gate. /upgrade/:bizId is a tier ladder for a single
// business; reads from tier_pricing (seeded by build-virtual-gallery.sh).
app.get('/api/upgrade/:bizId', async (req, res) => {
  const bizId = parseInt(String(req.params.bizId), 10);
  if (!Number.isInteger(bizId)) return res.status(400).json({ ok:false, error:'bad bizId' });
  try {
    const biz = await query<any>(`
      SELECT id, name, category, category_naics, address, city, lat, lng, website, tier
      FROM businesses WHERE id = $1 LIMIT 1`, [bizId]);
    if (!biz.rows.length) return res.status(404).json({ ok:false, error:'business not found' });
    const tiers = await query<any>(`
      SELECT category, tier, display_name, price_cents, marketing_copy
      FROM tier_pricing WHERE category = $1 ORDER BY price_cents ASC`,
      [biz.rows[0].category]);
    res.json({ ok:true, business: biz.rows[0], tiers: tiers.rows });
  } catch (e:any) { res.status(500).json({ ok:false, error: e.message }); }
});

app.post('/api/upgrade/:bizId/intent', async (req, res) => {
  const bizId = parseInt(String(req.params.bizId), 10);
  const { tier, email, name, notes } = req.body || {};
  if (!Number.isInteger(bizId) || !tier || !email) {
    return res.status(400).json({ ok:false, error:'bizId + tier + email required' });
  }
  try {
    const r = await query<any>(`
      INSERT INTO upgrade_intents (business_id, requested_tier, contact_email, contact_name, notes, ip_addr)
      VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`,
      [bizId, tier, email, name || null, notes || null,
       req.headers['x-forwarded-for'] || req.socket.remoteAddress || null]);
    res.json({ ok:true, intent: r.rows[0] });
  } catch (e:any) { res.status(500).json({ ok:false, error: e.message }); }
});

// SSR-ish: route /upgrade/:bizId to the upgrade.html shell — the page reads
// bizId from window.location.pathname and hydrates via /api/upgrade/:bizId.
app.get('/upgrade/:bizId([0-9]+)', (req, res) => {
  const here = path.dirname(fileURLToPath(import.meta.url));
  res.sendFile(path.join(here, '..', '..', 'public', 'upgrade.html'));
});

// Admin: upgrade leads board. Already behind Basic-auth via ADMIN_PATHS.
app.get('/api/admin/upgrade-leads', async (_req, res) => {
  try {
    const r = await query<any>(`
      SELECT ui.id, ui.requested_tier, ui.contact_email, ui.contact_name, ui.notes,
             ui.ip_addr, ui.created_at,
             b.id AS business_id, b.name AS business_name, b.category, b.address, b.website,
             tp.price_cents, tp.display_name AS tier_display
      FROM upgrade_intents ui
      LEFT JOIN businesses b ON b.id = ui.business_id
      LEFT JOIN tier_pricing tp ON tp.category = b.category AND tp.tier = ui.requested_tier
      ORDER BY ui.created_at DESC LIMIT 500
    `);
    res.json({ ok:true, items: r.rows });
  } catch (e:any) { res.status(500).json({ ok:false, error: e.message }); }
});
app.get('/admin/upgrade-leads', async (_req, res) => {
  try {
    const r = await query<any>(`
      SELECT ui.id, ui.requested_tier, ui.contact_email, ui.contact_name, ui.notes,
             ui.created_at, b.id AS business_id, b.name AS business_name, b.category,
             tp.price_cents, tp.display_name AS tier_display
      FROM upgrade_intents ui
      LEFT JOIN businesses b ON b.id = ui.business_id
      LEFT JOIN tier_pricing tp ON tp.category = b.category AND tp.tier = ui.requested_tier
      ORDER BY ui.created_at DESC LIMIT 500
    `);
    const total = r.rows.length;
    const pipelineCents = r.rows.reduce((s: number, x: any) => s + (parseInt(x.price_cents,10)||0), 0);
    const esc = (s: any) => String(s||'').replace(/[&<>"]/g, (c: string) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'} as any)[c]);
    const fmt = (d: any) => d ? new Date(d).toISOString().slice(0,16).replace('T',' ') : '—';
    const dollars = (cents: number) => cents === 0 ? 'Free' : '$' + (cents/100).toFixed(0);
    res.type('html').send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Upgrade Leads — Ventura Corridor</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;500;600;700&display=swap">
<style>
:root{--bg:#0e0e0e;--panel:#181613;--fg:#f1ece2;--muted:#9a8f7e;--rule:rgba(255,255,255,0.10);--gold:#c9a96e;--green:#7ee69b}
*{box-sizing:border-box}body,html{margin:0;padding:0;background:var(--bg);color:var(--fg);font-family:'Inter',sans-serif}
header{padding:22px 28px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center}
header h1{margin:0;font-family:'Playfair Display',serif;font-size:1.6em}header h1 small{display:block;color:var(--muted);font-family:'Inter',sans-serif;font-size:0.5em;letter-spacing:2px;text-transform:uppercase;margin-top:6px;font-weight:500}
main{padding:28px;max-width:1240px;margin:0 auto}
.stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:28px}
.stat{background:var(--panel);border:1px solid var(--rule);border-radius:12px;padding:18px 22px}
.stat .label{color:var(--muted);font-size:0.7em;letter-spacing:2px;text-transform:uppercase;font-weight:600;margin-bottom:6px}
.stat .num{font-family:'Playfair Display',serif;font-size:1.85em;font-weight:700;color:var(--gold)}
table{width:100%;border-collapse:collapse;font-size:0.9em;background:var(--panel);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
th{background:rgba(255,255,255,0.03);text-align:left;padding:11px 14px;color:var(--muted);font-weight:600;font-size:0.72em;letter-spacing:1.5px;text-transform:uppercase}
td{padding:11px 14px;border-bottom:1px solid var(--rule)}tr:last-child td{border-bottom:0}tr:hover td{background:rgba(255,255,255,0.02)}
.empty{padding:60px;text-align:center;color:var(--muted)}
a{color:var(--gold);text-decoration:none}.tiny{font-size:0.78em;color:var(--muted)}
.badge{display:inline-block;padding:3px 8px;border-radius:999px;font-size:0.7em;letter-spacing:1px;text-transform:uppercase;border:1px solid var(--rule);color:var(--muted)}
.badge.gold{border-color:var(--gold);color:var(--gold)}
.num{text-align:right;color:var(--green);font-weight:600}
@media (max-width:780px){.stats{grid-template-columns:1fr}table{font-size:0.78em}th,td{padding:8px 10px}main{padding:18px 14px}}
</style></head><body>
<header><h1>Upgrade Leads <small>Captured intents from /upgrade/:bizId</small></h1><a class="tiny" href="/">← back</a></header>
<main>
<div class="stats">
  <div class="stat"><div class="label">Total intents</div><div class="num">${total}</div></div>
  <div class="stat"><div class="label">Pipeline (MRR if all convert)</div><div class="num">${dollars(pipelineCents)}/mo</div></div>
  <div class="stat"><div class="label">Average ticket</div><div class="num">${total ? dollars(Math.round(pipelineCents/total)) : '—'}</div></div>
</div>
${r.rows.length === 0
  ? `<div class="empty"><h2 style="font-family:'Playfair Display',serif;color:var(--fg);margin:0 0 8px">No leads yet</h2><p>Intents land here when a business owner clicks 'Notify me when this tier opens' on /upgrade/:bizId.</p></div>`
  : `<table><thead><tr>
      <th>When</th><th>Business</th><th>Requested tier</th><th>Contact</th><th>Notes</th><th>Price</th>
    </tr></thead><tbody>
    ${r.rows.map((row: any) => `<tr>
      <td>${fmt(row.created_at)}</td>
      <td><a href="/upgrade/${row.business_id}" target="_blank">${esc(row.business_name||'(unknown)')}</a><div class="tiny">${esc(row.category||'')}</div></td>
      <td><span class="badge gold">${esc(row.tier_display || row.requested_tier)}</span></td>
      <td>${esc(row.contact_name || '')}<div class="tiny"><a href="mailto:${esc(row.contact_email)}">${esc(row.contact_email)}</a></div></td>
      <td class="tiny">${esc(row.notes||'—').slice(0,80)}</td>
      <td class="num">${dollars(row.price_cents||0)}</td>
    </tr>`).join('')}
    </tbody></table>`}
</main></body></html>`);
  } catch (e:any) { res.status(500).send('error: ' + e.message); }
});

// ─── Blvd Gallery — virtual art walk along Ventura Blvd ──────────────
// One imagined piece of art per business, generated overnight by qwen3:14b.
// East (Encino, position_pct=0) → West (Studio City, position_pct=100).
app.get('/api/gallery/pieces', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '500'), 10) || 500, 5000);
    const neighborhood = String(req.query.neighborhood ?? '');
    const where = ['g.business_id IS NOT NULL'];
    const params: any[] = [];
    if (neighborhood) { params.push(neighborhood); where.push(`g.neighborhood = $${params.length}`); }
    params.push(limit);
    const r = await query(`
      SELECT g.id, g.business_id, g.art_title, g.art_medium, g.art_year_est,
             g.art_description, g.color_palette, g.image_url, g.image_source,
             g.lat, g.lon, g.neighborhood, g.position_pct, g.generator,
             b.name AS business_name, b.category AS business_category,
             b.address AS business_address
      FROM gallery_pieces g
      LEFT JOIN businesses b ON b.id = g.business_id
      WHERE ${where.join(' AND ')}
      ORDER BY g.position_pct ASC NULLS LAST, g.id ASC
      LIMIT $${params.length}
    `, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});
app.get('/api/gallery/stats', async (_req, res) => {
  try {
    const r = await query(`
      SELECT neighborhood, COUNT(*)::int AS n
      FROM gallery_pieces
      GROUP BY neighborhood
      ORDER BY MIN(position_pct) ASC
    `);
    const total = await query(`SELECT COUNT(*)::int AS n FROM gallery_pieces`);
    res.json({ total: total.rows[0].n, byNeighborhood: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Build status — live progress strip in viewer ─────────────────────
app.get('/api/build-status', async (_req, res) => {
  try {
    const r = await query<{ key: string; value: string }>(`
      SELECT 'businesses' AS key, COUNT(*)::text AS value FROM businesses WHERE on_corridor
      UNION ALL SELECT 'with_site', COUNT(*)::text FROM businesses WHERE on_corridor AND website IS NOT NULL
      UNION ALL SELECT 'audited',  COUNT(DISTINCT business_id)::text FROM front_page_audits a JOIN businesses b ON b.id=a.business_id WHERE a.error_message IS NULL AND b.on_corridor
      UNION ALL SELECT 'scored',   COUNT(DISTINCT business_id)::text FROM seo_scores s JOIN businesses b ON b.id=s.business_id WHERE b.on_corridor
      UNION ALL SELECT 'enriched', COUNT(*)::text FROM business_enrichment e JOIN businesses b ON b.id=e.business_id WHERE b.on_corridor
      UNION ALL SELECT 'with_owner_contact', COUNT(DISTINCT business_id)::text FROM business_contacts WHERE person_name IS NOT NULL
      UNION ALL SELECT 'tier_a', COUNT(*)::text FROM seo_scores WHERE tier='A'
      UNION ALL SELECT 'tier_b', COUNT(*)::text FROM seo_scores WHERE tier='B'
      UNION ALL SELECT 'tier_c', COUNT(*)::text FROM seo_scores WHERE tier='C'
      UNION ALL SELECT 'tier_d', COUNT(*)::text FROM seo_scores WHERE tier='D'
      UNION ALL SELECT 'class_independent', COUNT(*)::text FROM business_enrichment WHERE ownership_class='independent'
      UNION ALL SELECT 'class_corporate', COUNT(*)::text FROM business_enrichment WHERE ownership_class='corporate'
      UNION ALL SELECT 'class_franchise', COUNT(*)::text FROM business_enrichment WHERE ownership_class='franchise'
      UNION ALL SELECT 'news_total', COUNT(*)::text FROM news_items
      UNION ALL SELECT 'news_biz', COUNT(DISTINCT business_id)::text FROM news_items
      UNION ALL SELECT 'news_fresh_7d', COUNT(*)::text FROM news_items WHERE fetched_at > now() - interval '7 days'
    `);
    // Idea-loop accepted + rejected counts — read from JSONL on disk
    let ideaCount = 0, ideaRejects = 0;
    try {
      const fs2 = await import('node:fs/promises');
      const path2 = await import('node:path');
      const home = process.env.HOME || require('os').homedir() || '';
      if (home) {
        const dir = path2.join(home, '.claude', 'skills', 'idea-loop', 'data');
        try {
          const txt = await fs2.readFile(path2.join(dir, 'ideas.jsonl'), 'utf8');
          ideaCount = txt.split(/\r?\n/).filter(Boolean).length;
        } catch {}
        try {
          const txt = await fs2.readFile(path2.join(dir, 'rejects.jsonl'), 'utf8');
          ideaRejects = txt.split(/\r?\n/).filter(Boolean).length;
        } catch {}
      }
    } catch {}
    const out: Record<string, number> = {};
    for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
    out.ideas_accepted = ideaCount;
    out.ideas_rejected = ideaRejects;
    // Last 5 ingest_runs for the activity tail
    const runs = await query(`
      SELECT id, source, started_at, finished_at, rows_in, rows_new, error_message, notes
      FROM ingest_runs ORDER BY id DESC LIMIT 5
    `);
    res.json({ counts: out, recent_runs: runs.rows, ts: new Date().toISOString() });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── CSV export — full intel for any subset of corridor businesses (admin-only) ──
// Query params:
//   ?vertical=furniture     filter to NAICS keyword
//   ?paid_only=1            only businesses with at least 1 paid-ad pixel
//   ?live_ads=1             only businesses with live ads in Transparency Center
function csvField(v: any): string {
  if (v == null) return '';
  let s = typeof v === 'string' ? v : JSON.stringify(v);
  // OWASP CSV-injection defang: leading =/+/-/@/tab/CR run as Excel formula.
  if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
  if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
  return s;
}
app.get('/api/export.csv', async (req, res) => {
  try {
    const where = ['b.on_corridor'];
    const params: any[] = [];
    const vertical = String(req.query.vertical || '').trim();
    if (vertical) {
      params.push('%' + vertical + '%');
      where.push(`b.raw->>'primary_naics_description' ILIKE $${params.length}`);
    }
    if (String(req.query.paid_only || '') === '1') where.push(`(e.ad_signals->>'paid_ads_count')::int > 0`);
    if (String(req.query.live_ads || '') === '1') where.push(`(e.ad_signals->'transparency'->>'ad_count')::int > 0`);
    const r = await query<any>(`
      SELECT b.id, b.name, b.address, b.city, b.zip, b.website,
             b.raw->>'primary_naics_description' AS naics,
             COALESCE((e.ad_signals->>'paid_ads_count')::int, 0) AS paid_pixel_count,
             COALESCE((e.ad_signals->'transparency'->>'ad_count')::int, 0) AS live_google_ads,
             (e.ad_signals->>'google_ads')::bool AS google_ads,
             (e.ad_signals->>'meta_pixel')::bool AS meta_pixel,
             (e.ad_signals->>'tiktok_pixel')::bool AS tiktok_pixel,
             (e.ad_signals->>'pinterest_tag')::bool AS pinterest_tag,
             (e.ad_signals->>'linkedin_insight')::bool AS linkedin_insight,
             (e.ad_signals->>'twitter_pixel')::bool AS twitter_pixel,
             (e.ad_signals->>'reddit_pixel')::bool AS reddit_pixel,
             (e.ad_signals->>'bing_uet')::bool AS bing_uet,
             (e.ad_signals->>'snap_pixel')::bool AS snap_pixel,
             (e.ad_signals->>'gtm_container')::bool AS uses_gtm,
             (e.ad_signals ? 'gtm_followthrough') AS gtm_second_stage_detected,
             e.headline,
             e.headline_voice_label AS voice,
             (SELECT string_agg(DISTINCT k, '; ' ORDER BY k) FROM jsonb_object_keys(e.chamber_memberships) k) AS chamber_memberships,
             e.crawl_blocked,
             s.total AS seo_score, s.tier AS seo_tier,
             (SELECT string_agg(DISTINCT person_email, '; ') FROM business_contacts WHERE business_id = b.id AND person_email IS NOT NULL) AS emails,
             (SELECT string_agg(DISTINCT source_url, '; ') FROM business_contacts WHERE business_id = b.id AND notes = 'instagram') AS instagram_urls,
             (SELECT string_agg(DISTINCT source_url, '; ') FROM business_contacts WHERE business_id = b.id AND notes = 'facebook') AS facebook_urls,
             (SELECT string_agg(DISTINCT source_url, '; ') FROM business_contacts WHERE business_id = b.id AND notes = 'twitter') AS twitter_urls,
             (SELECT string_agg(DISTINCT person_linkedin, '; ') FROM business_contacts WHERE business_id = b.id AND role = 'linkedin_company') AS linkedin_urls
      FROM businesses b
      LEFT JOIN business_enrichment e ON e.business_id = b.id
      LEFT JOIN LATERAL (
        SELECT total, tier FROM seo_scores WHERE business_id = b.id ORDER BY scored_at DESC LIMIT 1
      ) s ON TRUE
      WHERE ${where.join(' AND ')}
      ORDER BY live_google_ads DESC NULLS LAST,
               paid_pixel_count DESC NULLS LAST,
               b.name ASC
    `, params);
    const cols = Object.keys(r.rows[0] || { id: null });
    const lines = [cols.join(',')];
    for (const row of r.rows) lines.push(cols.map((c) => csvField(row[c])).join(','));
    res.set('Content-Type', 'text/csv; charset=utf-8');
    res.set('Content-Disposition', `attachment; filename="ventura-corridor-${Date.now()}.csv"`);
    res.send(lines.join('\n'));
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── DW competitive radar — interior/furniture/design/wallcover/floor on corridor ─
app.get('/api/dw-radar', async (_req, res) => {
  try {
    const r = await query(`
      SELECT b.id, b.name, b.address, b.city, b.zip, b.lat, b.lng, b.website,
             b.raw->>'primary_naics_description' AS naics_desc,
             b.raw->>'naics' AS naics_code,
             e.ad_signals,
             s.total AS seo_score, s.tier AS seo_tier,
             a.screenshot_path,
             COALESCE(c.contacts, '[]'::jsonb) AS contacts,
             COALESCE(c.contact_count, 0) AS contact_count
      FROM businesses b
      LEFT JOIN business_enrichment e ON e.business_id = b.id
      LEFT JOIN LATERAL (
        SELECT total, tier FROM seo_scores
        WHERE business_id = b.id ORDER BY scored_at DESC LIMIT 1
      ) s ON TRUE
      LEFT JOIN LATERAL (
        SELECT screenshot_path FROM front_page_audits
        WHERE business_id = b.id AND screenshot_path IS NOT NULL
          AND error_message IS NULL
        ORDER BY audited_at DESC LIMIT 1
      ) a ON TRUE
      LEFT JOIN LATERAL (
        SELECT
          jsonb_agg(jsonb_build_object(
            'role', role, 'person_name', person_name, 'person_email', person_email,
            'person_linkedin', person_linkedin, 'source_url', source_url, 'notes', notes
          )) AS contacts,
          COUNT(*)::int AS contact_count
        FROM business_contacts WHERE business_id = b.id
      ) c ON TRUE
      WHERE b.on_corridor AND (
        b.id = 17463  -- always include Designer Wallcoverings
        OR b.category_raw ILIKE '%wallcover%'
        OR b.raw->>'primary_naics_description' ILIKE '%furniture%'
        OR b.raw->>'primary_naics_description' ILIKE '%interior%'
        OR b.raw->>'primary_naics_description' ILIKE '%home furnish%'
        OR b.raw->>'primary_naics_description' ILIKE '%wallcover%'
        OR b.raw->>'primary_naics_description' ILIKE '%paint%'
        OR b.raw->>'primary_naics_description' ILIKE '%floor%'
        OR b.raw->>'primary_naics_description' ILIKE '%window%treat%'
        OR b.raw->>'primary_naics_description' ILIKE '%upholst%'
        OR b.raw->>'primary_naics_description' ILIKE '%design%service%'
        OR b.raw->>'primary_naics_description' ILIKE '%lighting%'
        OR b.raw->>'primary_naics_description' ILIKE '%flooring%'
      )
      ORDER BY (e.ad_signals->>'paid_ads_count')::int DESC NULLS LAST,
               s.total DESC NULLS LAST,
               b.name ASC
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Ad / Social signals — feeds the /signals.html grid ───────────────
app.get('/api/signals', async (req, res) => {
  try {
    const limitRaw = parseInt(String(req.query.limit ?? '5000'), 10);
    const limit = isNaN(limitRaw) || limitRaw < 0 ? 5000 : Math.min(limitRaw, 25000);
    const r = await query(`
      SELECT b.id, b.name, b.category, b.address, b.city, b.zip,
             b.lat, b.lng, b.website, b.source,
             e.ad_signals,
             e.chamber_memberships,
             e.ownership_class, e.parent_brand,
             s.total AS seo_score, s.tier AS seo_tier,
             a.screenshot_path,
             COALESCE(c.contacts, '[]'::jsonb) AS contacts
      FROM businesses b
      LEFT JOIN business_enrichment e ON e.business_id = b.id
      LEFT JOIN LATERAL (
        SELECT total, tier FROM seo_scores
        WHERE business_id = b.id ORDER BY scored_at DESC LIMIT 1
      ) s ON TRUE
      LEFT JOIN LATERAL (
        SELECT screenshot_path FROM front_page_audits
        WHERE business_id = b.id AND screenshot_path IS NOT NULL
          AND error_message IS NULL
        ORDER BY audited_at DESC LIMIT 1
      ) a ON TRUE
      LEFT JOIN LATERAL (
        SELECT jsonb_agg(jsonb_build_object(
          'role', role, 'person_name', person_name, 'person_email', person_email,
          'person_linkedin', person_linkedin, 'source_url', source_url, 'notes', notes
        )) AS contacts
        FROM business_contacts WHERE business_id = b.id
      ) c ON TRUE
      WHERE b.on_corridor AND e.ad_signals IS NOT NULL
      ORDER BY (e.ad_signals->>'paid_ads_count')::int DESC NULLS LAST,
               s.total DESC NULLS LAST
      LIMIT $1
    `, [limit]);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Top-advertiser leaderboard (transparency.ad_count + paid_ads_count) ─
app.get('/api/leaderboard', async (req, res) => {
  try {
    const limitRaw = parseInt(String(req.query.limit ?? '20'), 10);
    const limit = isNaN(limitRaw) || limitRaw < 1 ? 20 : Math.min(limitRaw, 100);
    const r = await query(`
      SELECT
        b.id, b.name, b.category, b.city, b.website,
        (e.ad_signals->'transparency'->>'ad_count')::int AS live_ads,
        (e.ad_signals->>'paid_ads_count')::int AS paid_platforms,
        (e.ad_signals ? 'gtm_followthrough') AS via_gtm,
        e.ad_signals->'transparency'->>'fetched_at' AS confirmed_at
      FROM businesses b
      JOIN business_enrichment e ON e.business_id = b.id
      WHERE b.on_corridor
        AND (e.ad_signals->'transparency'->>'ad_count')::int > 0
      ORDER BY live_ads DESC
      LIMIT $1
    `, [limit]);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Corridor segments — for the polyline overlay ─────────────────────
app.get('/api/segments', async (_req, res) => {
  try {
    const r = await query(`
      SELECT ord, city, west_lat, west_lng, east_lat, east_lng
      FROM corridor_segments ORDER BY ord ASC
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Single business detail ────────────────────────────────────────────
app.get('/api/businesses/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const b = await query(`SELECT * FROM businesses WHERE id = $1`, [id]);
    if (!b.rowCount) return res.status(404).json({ error: 'not found' });
    const a = await query(
      `SELECT * FROM front_page_audits WHERE business_id = $1 ORDER BY audited_at DESC LIMIT 1`,
      [id],
    );
    const s = await query(
      `SELECT * FROM seo_scores WHERE business_id = $1 ORDER BY scored_at DESC LIMIT 1`,
      [id],
    );
    const e = await query(
      `SELECT * FROM business_enrichment WHERE business_id = $1`, [id],
    );
    const c = await query(
      `SELECT id, role, person_name, person_title, person_linkedin, person_email, person_phone,
              source, source_url, confidence, last_verified_at, notes
       FROM business_contacts WHERE business_id = $1
       ORDER BY confidence DESC NULLS LAST, role ASC`, [id],
    );
    // News items scraped from this business's website. Magazine surface uses
    // these to show "what's new from this vendor" on the profile detail.
    const n = await query(
      `SELECT id, source_url, title, excerpt, summary, published_guess, fetched_at
         FROM news_items WHERE business_id = $1
        ORDER BY COALESCE(published_guess, fetched_at::date) DESC,
                 fetched_at DESC
        LIMIT 10`, [id]
    );
    // Recency stats — "fresh" = fetched within the last 7 days, used by the
    // map's recency badge so editors can spot active publishers at a glance.
    const ns = await query(
      `SELECT
         COUNT(*) FILTER (WHERE fetched_at > now() - interval '7 days')::int AS fresh_7d,
         COUNT(*) FILTER (WHERE fetched_at > now() - interval '30 days')::int AS fresh_30d,
         MAX(fetched_at) AS last_at
       FROM news_items WHERE business_id = $1`, [id]
    );
    res.json({
      business: b.rows[0],
      latest_audit: a.rows[0] || null,
      latest_score: s.rows[0] || null,
      enrichment: e.rows[0] || null,
      contacts: c.rows,
      news: n.rows,
      news_stats: ns.rows[0] || { fresh_7d: 0, fresh_30d: 0, last_at: null },
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Polyline (Ventura Blvd centerline) — for the 3D viewer ───────────
app.get('/api/polyline', (_req, res) => {
  res.sendFile(path.resolve(__dirname, '../../data/ventura-blvd-polyline.json'));
});

// ─── Pitches — DW outreach pipeline ──────────────────────────────────
app.get('/api/pitches', async (req, res) => {
  try {
    const status  = String(req.query.status   || '').trim();
    const pri     = String(req.query.priority || '').trim();
    const channel = String(req.query.channel  || '').trim();
    const id      = String(req.query.id       || '').trim();
    const limit   = Math.min(parseInt(String(req.query.limit ?? '5500'), 10) || 5500, 6000);
    const where: string[] = [];
    const params: any[] = [];
    if (status)  { params.push(status); where.push(`p.status = $${params.length}`); }
    if (pri)     { params.push(parseInt(pri, 10)); where.push(`p.priority = $${params.length}`); }
    if (channel) { params.push(channel); where.push(`p.outreach_channel = $${params.length}`); }
    if (id)      { params.push(parseInt(id, 10)); where.push(`p.id = $${params.length}`); }
    params.push(limit);
    const r = await query(`
      SELECT p.id, p.business_id, p.pitch_type, p.priority, p.status, p.dw_proximity, p.outreach_channel,
             p.subject, p.body, p.observation, p.why_dw_fits,
             p.research_links, p.pitch_md_path,
             p.contact_name, p.email, p.phone, p.website, p.instagram, p.linkedin,
             p.li_connection_status, p.li_dm_sent_at, p.li_invite_sent_at,
             p.created_at, p.updated_at, p.scrubbed_at, p.approved_at,
             p.sent_at, p.replied_at, p.outcome, p.notes,
             p.reply_text, p.reply_channel, p.won_value_usd, p.lost_reason,
             p.next_followup_at, p.followup_notes,
             b.name, b.address, b.city, b.zip,
             b.raw->>'primary_naics_description' AS naics,
             COALESCE(n.news_count, 0)::int     AS news_count,
             COALESCE(n.fresh_7d, 0)::int       AS news_fresh_7d,
             n.last_news_at
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      LEFT JOIN LATERAL (
        SELECT COUNT(*) AS news_count,
               COUNT(*) FILTER (WHERE fetched_at > now() - interval '7 days') AS fresh_7d,
               MAX(fetched_at) AS last_news_at
          FROM news_items WHERE business_id = b.id
      ) n ON TRUE
      ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
      ORDER BY p.priority ASC, b.name ASC
      LIMIT $${params.length}
    `, params);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Postcard batch in Lob's expected schema (https://lob.com/docs#postcards-create)
// One row per addressee. Columns map to Lob's required fields.
app.get('/api/pitches/postcard-batch.csv', async (req, res) => {
  try {
    const minProx = String(req.query.min_proximity || 'walk_10min'); // default: ≤0.5mi
    const tierOrder = ['same_building','same_block','walk_2min','walk_5min','walk_10min','walk_15min'];
    const minIdx = tierOrder.indexOf(minProx);
    const allowedProx = minIdx >= 0 ? tierOrder.slice(0, minIdx + 1) : tierOrder;
    const r = await query(`
      SELECT p.id, b.name, b.address, b.city, b.zip,
             p.dw_proximity, p.pitch_type, p.contact_name, p.subject, p.body
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      WHERE p.outreach_channel = 'postcard'
        AND p.status NOT IN ('skip','sent','replied','won','lost')
        AND ($1::text[] IS NULL OR p.dw_proximity = ANY($1))
        AND b.address IS NOT NULL AND b.city IS NOT NULL AND b.zip IS NOT NULL
      ORDER BY
        CASE p.dw_proximity
          WHEN 'same_building' THEN 0 WHEN 'same_block' THEN 1
          WHEN 'walk_2min' THEN 2 WHEN 'walk_5min' THEN 3 WHEN 'walk_10min' THEN 4 WHEN 'walk_15min' THEN 5
          ELSE 9
        END,
        p.priority ASC, b.name ASC
    `, [allowedProx.length ? allowedProx : null]);
    // Lob CSV expects: to.name, to.address_line1, to.address_city, to.address_state, to.address_zip, etc.
    const cols = [
      'pitch_id', 'to.name', 'to.company', 'to.address_line1', 'to.address_city', 'to.address_state', 'to.address_zip',
      'merge.dw_proximity', 'merge.pitch_type', 'merge.subject', 'merge.body'
    ];
    const lines = [cols.join(',')];
    for (const row of r.rows) {
      lines.push([
        row.id,
        row.contact_name || row.name,
        row.name,
        row.address,
        row.city,
        'CA',
        row.zip,
        row.dw_proximity || '',
        row.pitch_type || '',
        row.subject || '',
        (row.body || '').replace(/\n/g, ' '),
      ].map(csvField).join(','));
    }
    res.set('Content-Type', 'text/csv; charset=utf-8');
    res.set('Content-Disposition', `attachment; filename="dw-postcard-batch-${minProx}-${Date.now()}.csv"`);
    res.send(lines.join('\n'));
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── LinkedIn subscription state + InMail credit tracker ─────────────
app.get('/api/linkedin/subscription', async (_req, res) => {
  try {
    const r = await query(`SELECT * FROM v_inmail_status`);
    const row = r.rows[0] || {};
    // Trial countdown
    if (row.trial_ends_at) {
      const days = Math.ceil((new Date(row.trial_ends_at).getTime() - Date.now()) / 86400000);
      row.trial_days_remaining = days;
      row.trial_status = days < 0 ? 'expired' : days <= 5 ? 'ending_soon' : 'active';
    }
    res.json(row);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});
app.patch('/api/linkedin/subscription', async (req, res) => {
  try {
    const allowed = ['tier','monthly_inmail_quota','trial_ends_at','active_since','monthly_cost_usd','notes'];
    const sets: string[] = []; const params: any[] = [];
    for (const k of allowed) if (k in (req.body||{})) { params.push(req.body[k]); sets.push(`${k}=$${params.length}`); }
    if (!sets.length) return res.status(400).json({ error: 'no fields' });
    sets.push('updated_at=NOW()');
    await query(`UPDATE linkedin_subscription SET ${sets.join(', ')} WHERE id=1`, params);
    res.json({ ok: true });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Mark a pitch as InMail-sent (decrements monthly credit)
app.post('/api/pitches/:id/inmail-sent', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const status = await query(`SELECT credits_remaining FROM v_inmail_status`);
    if ((status.rows[0]?.credits_remaining || 0) <= 0) {
      return res.status(429).json({ error: 'no_credits_remaining', remaining: 0, msg: 'Monthly InMail quota exhausted. Resets on the 1st.' });
    }
    await query(`
      UPDATE pitches SET
        inmail_sent_at = NOW(),
        inmail_credit_used = TRUE,
        outreach_channel = 'inmail',
        status = CASE WHEN status IN ('draft','researched','scrubbed','approved') THEN 'sent' ELSE status END,
        sent_at = COALESCE(sent_at, NOW())
      WHERE id = $1
    `, [id]);
    res.json({ ok: true });
  } catch (e: any) { res.status(500).json({ error: e.message }); }
});

// ─── Walk-route: optimal pedestrian sequence for today's drop ─────────
// Returns walking-distance pitches sorted by longitude (W → E) so Steve
// physically walks the corridor in one direction without backtracking.
app.get('/api/pitches/walk-route', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '30'), 10) || 30, 80);
    const direction = String(req.query.direction || 'east'); // 'east' | 'west'
    const sort = String(req.query.sort || 'geographic');     // 'geographic' | 'priority'

    let orderClause: string;
    let extraSelect = '';
    if (sort === 'priority') {
      // Group all doors at the same building together, ordered by that building's priority score.
      // priority_score is computed inline (same formula as /api/buildings/priority).
      extraSelect = `,
        TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address,
        ROUND((br.unpitched::numeric / GREATEST(br.dw_miles, 0.05)::numeric)::numeric, 1) AS priority_score`;
      orderClause = `(br.unpitched::numeric / GREATEST(br.dw_miles, 0.05)::numeric) DESC NULLS LAST, br.bldg_address ASC, p.priority ASC`;
    } else {
      orderClause = `b.lng ${direction === 'west' ? 'DESC' : 'ASC'}, b.lat ASC`;
    }

    const joinClause = sort === 'priority'
      ? `LEFT JOIN v_building_roster br
           ON br.bldg_address = TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i'))`
      : '';

    const r = await query(`
      SELECT p.id, p.pitch_type, p.priority, p.status, p.dw_proximity, p.li_message,
             b.name, b.address, b.city, b.zip, b.lat, b.lng,
             p.research_links->>'gmaps' AS gmaps_url
             ${extraSelect}
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      ${joinClause}
      WHERE p.dw_proximity IN ('same_building','same_block','walk_2min','walk_5min','walk_10min')
        AND p.status NOT IN ('skip','sent','won','lost')
        AND b.lat IS NOT NULL AND b.lng IS NOT NULL
        AND b.merged_into IS NULL
      ORDER BY ${orderClause}
      LIMIT $1
    `, [limit]);
    res.json({ count: r.rowCount, direction, sort, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── LinkedIn: connections, posts, engagement queue ──────────────────
app.use(express.text({ type: 'text/csv', limit: '20mb' }));

// Import LinkedIn Connections.csv (paste body or upload)
// User downloads from: linkedin.com → Settings → Privacy → Get a copy of your data → Connections
app.post('/api/linkedin/import-connections', async (req, res) => {
  try {
    const csv = typeof req.body === 'string' ? req.body : (req.body?.csv || '');
    if (!csv || csv.length < 50) return res.status(400).json({ error: 'paste connections CSV in body (text/csv)' });
    // LinkedIn export header is preceded by a "Notes" preamble; skip until we see a "First Name" header
    const lines = csv.split(/\r?\n/);
    const headerIdx = lines.findIndex(l => /^"?First Name"?,/i.test(l));
    if (headerIdx === -1) return res.status(400).json({ error: 'unexpected CSV; expected "First Name,…" header row' });
    const header = lines[headerIdx].split(',').map(c => c.replace(/^"|"$/g, '').trim().toLowerCase());
    const idx = (k: string) => header.findIndex(h => h.replace(/[^a-z]/g, '') === k.replace(/[^a-z]/g, ''));
    const fnI = idx('first name'), lnI = idx('last name'), urlI = idx('url'), emI = idx('email address');
    const coI = idx('company'), posI = idx('position'), conI = idx('connected on');
    let inserted = 0, updated = 0;
    for (let i = headerIdx + 1; i < lines.length; i++) {
      const line = lines[i];
      if (!line.trim()) continue;
      // crude CSV split (LinkedIn export rarely has embedded commas; fields without quotes are common)
      const cells = line.split(',').map(c => c.replace(/^"|"$/g, '').trim());
      const fn = cells[fnI] || '', ln = cells[lnI] || '', url = cells[urlI] || '';
      if (!fn && !ln && !url) continue;
      const company = cells[coI] || '';
      const position = cells[posI] || '';
      const email = cells[emI] || null;
      const connected = cells[conI] || null;
      const r = await query(`
        INSERT INTO linkedin_connections (first_name, last_name, full_name, profile_url, email, company, position, connected_on)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8::date)
        ON CONFLICT (profile_url) DO UPDATE SET
          first_name=EXCLUDED.first_name, last_name=EXCLUDED.last_name, full_name=EXCLUDED.full_name,
          email=COALESCE(EXCLUDED.email, linkedin_connections.email),
          company=EXCLUDED.company, position=EXCLUDED.position, connected_on=EXCLUDED.connected_on,
          imported_at=NOW()
        RETURNING (xmax = 0) AS was_insert
      `, [fn, ln, [fn, ln].filter(Boolean).join(' '), url || null, email, company, position, connected]);
      if (r.rows[0]?.was_insert) inserted++; else updated++;
    }

    // Auto-match against pitches by fuzzy company name
    const matchR = await query(`
      WITH norm_li AS (
        SELECT id AS li_id, full_name, company,
               regexp_replace(upper(coalesce(company,'')), '[^A-Z0-9 ]', '', 'g') AS ncompany
        FROM linkedin_connections WHERE matched_pitch_id IS NULL
      ),
      norm_p AS (
        SELECT p.id AS pitch_id,
               regexp_replace(upper(b.name), '[^A-Z0-9 ]', '', 'g') AS nname
        FROM pitches p JOIN businesses b ON b.id = p.business_id
      )
      UPDATE linkedin_connections lc
      SET matched_pitch_id = m.pitch_id, match_confidence = m.score
      FROM (
        SELECT li.li_id, p.pitch_id,
               GREATEST(
                 CASE WHEN li.ncompany <> '' AND p.nname <> '' AND li.ncompany = p.nname THEN 1.0
                      WHEN length(li.ncompany) > 8 AND p.nname LIKE li.ncompany||'%' THEN 0.8
                      WHEN length(li.ncompany) > 8 AND li.ncompany LIKE p.nname||'%' THEN 0.7
                      WHEN left(li.ncompany,12) <> '' AND left(li.ncompany,12) = left(p.nname,12) THEN 0.6
                      ELSE 0 END
               ) AS score
        FROM norm_li li, norm_p p
        WHERE li.ncompany <> ''
      ) m
      WHERE lc.id = m.li_id AND m.score >= 0.6
        AND m.pitch_id = (
          SELECT pitch_id FROM (
            SELECT pitch_id,
                   GREATEST(
                     CASE WHEN lc2.ncompany = p2.nname THEN 1.0
                          WHEN length(lc2.ncompany) > 8 AND p2.nname LIKE lc2.ncompany||'%' THEN 0.8
                          WHEN length(lc2.ncompany) > 8 AND lc2.ncompany LIKE p2.nname||'%' THEN 0.7
                          WHEN left(lc2.ncompany,12) = left(p2.nname,12) THEN 0.6
                          ELSE 0 END
                   ) s
            FROM norm_li lc2, norm_p p2
            WHERE lc2.li_id = lc.id
            ORDER BY s DESC LIMIT 1
          ) sub
        );
    `);

    // After matching, also bump connection-status on pitches to '1st'
    await query(`
      UPDATE pitches SET li_connection_status = '1st'
      WHERE id IN (SELECT matched_pitch_id FROM linkedin_connections WHERE matched_pitch_id IS NOT NULL)
    `);

    const counts = await query(`
      SELECT count(*) AS total, count(*) FILTER (WHERE matched_pitch_id IS NOT NULL) AS matched FROM linkedin_connections
    `);
    res.json({ inserted, updated, ...counts.rows[0] });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// LinkedIn leads view — connections + their matched pitches, sorted by closest opportunity
app.get('/api/linkedin/leads', async (_req, res) => {
  try {
    const r = await query(`
      SELECT lc.id AS connection_id, lc.full_name, lc.profile_url, lc.email, lc.company, lc.position, lc.connected_on,
             lc.match_confidence,
             p.id AS pitch_id, p.pitch_type, p.priority, p.status, p.dw_proximity, p.li_message,
             p.li_invite_sent_at, p.li_invite_accepted_at, p.li_dm_sent_at,
             b.name AS business_name, b.address, b.city
      FROM linkedin_connections lc
      LEFT JOIN pitches p ON p.id = lc.matched_pitch_id
      LEFT JOIN businesses b ON b.id = p.business_id
      ORDER BY lc.matched_pitch_id IS NULL ASC,  -- matched first
               COALESCE(p.priority, 99) ASC,
               lc.connected_on DESC NULLS LAST
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Posts: list / draft / mark-as-posted
app.get('/api/linkedin/posts', async (_req, res) => {
  const r = await query(`SELECT * FROM linkedin_posts ORDER BY created_at DESC LIMIT 200`);
  res.json({ count: r.rowCount, rows: r.rows });
});
app.post('/api/linkedin/posts', async (req, res) => {
  try {
    const { topic, hook, body, hashtags, scheduled_at, status } = req.body || {};
    const r = await query(`
      INSERT INTO linkedin_posts (topic, hook, body, hashtags, scheduled_at, status)
      VALUES ($1,$2,$3,$4,$5,COALESCE($6,'draft'))
      RETURNING id
    `, [topic, hook, body, hashtags || [], scheduled_at || null, status || 'draft']);
    res.json({ ok: true, id: r.rows[0].id });
  } catch (e: any) { res.status(500).json({ error: e.message }); }
});
app.patch('/api/linkedin/posts/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const allowed = ['topic','hook','body','hashtags','status','scheduled_at','posted_at','external_url','reactions','comments','reposts','image_path'];
    const sets: string[] = []; const params: any[] = [];
    for (const k of allowed) if (k in (req.body||{})) { params.push(req.body[k]); sets.push(`${k}=$${params.length}`); }
    if (!sets.length) return res.status(400).json({ error: 'no fields' });
    sets.push('updated_at=NOW()');
    params.push(id);
    await query(`UPDATE linkedin_posts SET ${sets.join(', ')} WHERE id=$${params.length}`, params);
    res.json({ ok: true });
  } catch (e: any) { res.status(500).json({ error: e.message }); }
});

// Daily engagement queue — what to do today, ranked
app.get('/api/linkedin/queue', async (_req, res) => {
  try {
    const r = await query(`
      SELECT eq.*, p.pitch_type, p.dw_proximity, p.li_message, b.name AS business_name
      FROM linkedin_engagement_queue eq
      LEFT JOIN pitches p ON p.id = eq.pitch_id
      LEFT JOIN businesses b ON b.id = p.business_id
      WHERE eq.completed_at IS NULL
      ORDER BY eq.scheduled_for ASC NULLS LAST, eq.created_at ASC
      LIMIT 50
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) { res.status(500).json({ error: e.message }); }
});

// Hot list: walking-distance pitches with full contact info — ready to research/send
app.get('/api/pitches/hot-list.csv', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        p.priority, p.dw_proximity, p.pitch_type, p.status,
        b.name, b.address, b.city, b.zip,
        b.raw->>'primary_naics_description' AS naics,
        p.contact_name, p.email, p.phone, p.website, p.instagram, p.linkedin,
        p.research_links->>'gmaps' AS gmaps_url,
        p.research_links->>'google' AS google_url,
        p.subject, p.body
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      WHERE p.dw_proximity IN ('same_building','same_block','walk_2min','walk_5min','walk_10min')
        AND p.status NOT IN ('skip','lost','won')
      ORDER BY
        CASE p.dw_proximity
          WHEN 'same_building' THEN 0 WHEN 'same_block' THEN 1
          WHEN 'walk_2min' THEN 2 WHEN 'walk_5min' THEN 3 WHEN 'walk_10min' THEN 4 ELSE 5
        END,
        p.priority ASC, b.name ASC
    `);
    const cols = Object.keys(r.rows[0] || { id: null });
    const lines = [cols.join(',')];
    for (const row of r.rows) lines.push(cols.map((c) => csvField(row[c])).join(','));
    res.set('Content-Type', 'text/csv; charset=utf-8');
    res.set('Content-Disposition', `attachment; filename="dw-hot-list-${Date.now()}.csv"`);
    res.send(lines.join('\n'));
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.patch('/api/pitches/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const allowed = [
      'status','subject','body','observation','why_dw_fits','outcome','notes','pitch_md_path',
      'contact_name','email','phone','website','instagram','linkedin','outreach_channel',
      'reply_text','reply_channel','won_value_usd','lost_reason','next_followup_at','followup_notes'
    ];
    const sets: string[] = [];
    const params: any[] = [];
    for (const k of allowed) {
      if (k in (req.body || {})) {
        let v = req.body[k];
        // Coerce empty string → null for nullable optional fields so the UI
        // can clear a field by submitting "".
        if (v === '' && k !== 'status' && k !== 'pitch_type') v = null;
        params.push(v);
        sets.push(`${k} = $${params.length}`);
      }
    }
    // Auto-stamp lifecycle timestamps when status advances
    if (req.body?.status === 'scrubbed')   sets.push('scrubbed_at = NOW()');
    if (req.body?.status === 'approved')   sets.push('approved_at = NOW()');
    if (req.body?.status === 'sent')       sets.push('sent_at = COALESCE(sent_at, NOW())');
    if (req.body?.status === 'replied')    sets.push('replied_at = COALESCE(replied_at, NOW())');
    if (req.body?.status === 'won' || req.body?.status === 'lost') sets.push('closed_at = COALESCE(closed_at, NOW())');
    // If reply_text supplied without explicit status, still stamp replied_at
    if (req.body?.reply_text && req.body?.status !== 'replied' && !sets.some(s => s.startsWith('replied_at'))) {
      sets.push('replied_at = COALESCE(replied_at, NOW())');
    }
    // A reply implies a prior send happened — backfill sent_at if missing
    // so v_response_funnel includes it. Same for won/lost.
    if (
      req.body?.status === 'replied' ||
      req.body?.status === 'won' ||
      req.body?.status === 'lost' ||
      req.body?.reply_text
    ) {
      sets.push('sent_at = COALESCE(sent_at, NOW())');
    }
    if (!sets.length) return res.status(400).json({ error: 'no fields' });
    params.push(id);
    await query(`UPDATE pitches SET ${sets.join(', ')} WHERE id = $${params.length}`, params);
    res.json({ ok: true });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Crawl derby: per-lane progress for the corridor enrichment pipeline ──
// Powers /crawl-derby.html, the racetrack-style live status viewer.
// Each "horse" is one enrichment dimension; lap = % complete.
import { spawn } from 'node:child_process';
import { existsSync as fsExistsSync } from 'node:fs';

const _crawlChildren: Record<string, { pid: number; startedAt: number; status: 'running'|'done'|'error'; lastLog: string }> = {};

app.get('/api/crawl/status', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        count(*) FILTER (WHERE on_corridor)                                AS corridor_total,
        count(*) FILTER (WHERE on_corridor AND lat IS NOT NULL)            AS geocoded,
        count(*) FILTER (WHERE on_corridor AND website IS NOT NULL)        AS has_website,
        count(*) FILTER (WHERE on_corridor AND phone IS NOT NULL)          AS has_phone
      FROM businesses
    `);
    const a = await query(`SELECT count(*) AS audited FROM front_page_audits`);
    const e = await query(`SELECT count(*) FILTER (WHERE headline IS NOT NULL) AS enriched FROM business_enrichment`);
    const total = Number(r.rows[0].corridor_total);
    const lanes = [
      {
        id: 'geocode',  name: 'Geocode',     emoji: '📍',
        done: Number(r.rows[0].geocoded),    total,
        bottleneck: total - Number(r.rows[0].geocoded) < 100 ? 'almost done' : 'OSM/HERE backfill needed'
      },
      {
        id: 'website',  name: 'Website discovery', emoji: '🌐',
        done: Number(r.rows[0].has_website), total,
        bottleneck: 'needs HERE Maps key (free 30k/mo)'
      },
      {
        id: 'phone',    name: 'Phone discovery',   emoji: '📞',
        done: Number(r.rows[0].has_phone),   total,
        bottleneck: 'piggybacks on website crawl'
      },
      {
        id: 'audit',    name: 'Front-page audit',  emoji: '🔍',
        done: Number(a.rows[0].audited),     total: Math.max(Number(r.rows[0].has_website), 1),
        bottleneck: 'only audits businesses w/ a known website'
      },
      {
        id: 'enrich',   name: 'Headline + meta',   emoji: '📰',
        done: Number(e.rows[0].enriched),    total: Math.max(Number(r.rows[0].has_website), 1),
        bottleneck: 'depends on audit'
      }
    ].map(l => ({ ...l, pct: l.total ? Math.round(1000 * l.done / l.total) / 10 : 0 }));

    // Lane runtime status: running/idle
    for (const l of lanes) {
      const child = _crawlChildren[l.id];
      (l as any).runtime = child
        ? { pid: child.pid, status: child.status, started_at: child.startedAt, last_log: child.lastLog }
        : { status: 'idle' };
    }
    res.json({ lanes, corridor_total: total });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

const LANE_SCRIPTS: Record<string, { script: string; safe: boolean; note?: string }> = {
  geocode: { script: 'src/enrich/restaurant_owners.ts', safe: false, note: 'no dedicated geocode script wired yet — use HERE batch' },
  website: { script: 'src/enrich/website_via_brave.ts', safe: false, note: 'BRAVE quota — Steve has flagged Brave-blocked' },
  phone:   { script: 'src/enrich/scrape_contacts.ts',   safe: true,  note: 'scrapes contact pages from already-discovered sites' },
  audit:   { script: 'src/crawl/front_page.ts',         safe: true,  note: 'crawls 153 known websites — fast' },
  enrich:  { script: 'src/enrich/ad_signals.ts',        safe: true,  note: 'pixel/DNS sniff on known sites' },
};

app.post('/api/crawl/start', express.json(), async (req, res) => {
  try {
    const lane = String(req.body?.lane || '');
    const force = req.body?.force === true;
    const meta = LANE_SCRIPTS[lane];
    if (!meta) return res.status(400).json({ error: 'unknown lane' });
    if (!meta.safe && !force) return res.status(400).json({ error: 'unsafe lane — pass {force:true} to run anyway', note: meta.note });
    if (_crawlChildren[lane] && _crawlChildren[lane].status === 'running') {
      return res.json({ ok: true, already_running: true, pid: _crawlChildren[lane].pid });
    }
    const scriptPath = path.join(process.cwd(), meta.script);
    if (!fsExistsSync(scriptPath)) return res.status(404).json({ error: 'script missing', path: scriptPath });

    const child = spawn('npx', ['tsx', meta.script], {
      cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: false
    });
    _crawlChildren[lane] = { pid: child.pid || -1, startedAt: Date.now(), status: 'running', lastLog: '' };
    child.stdout.on('data', d => { _crawlChildren[lane].lastLog = String(d).slice(-200); });
    child.stderr.on('data', d => { _crawlChildren[lane].lastLog = '[err] ' + String(d).slice(-200); });
    child.on('exit', code => {
      _crawlChildren[lane].status = code === 0 ? 'done' : 'error';
      _crawlChildren[lane].lastLog += ` [exit ${code}]`;
    });
    res.json({ ok: true, lane, pid: child.pid, note: meta.note });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.post('/api/crawl/stop', express.json(), async (req, res) => {
  try {
    const lane = String(req.body?.lane || '');
    const child = _crawlChildren[lane];
    if (!child || child.status !== 'running') return res.json({ ok: true, was_running: false });
    try { process.kill(child.pid); } catch {}
    child.status = 'error';
    child.lastLog += ' [stopped]';
    res.json({ ok: true, lane });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Recent activity feed: stream of pitch_event_log events with business name ──
// Powers the "Recent activity" card on /today.html so Steve sees the corridor breathe.
app.get('/api/activity', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '30'), 10) || 30, 200);
    const eventType = String(req.query.event_type || '').trim();
    const where: string[] = [];
    const params: any[] = [];
    if (eventType) { params.push(eventType); where.push(`pel.event_type = $${params.length}`); }
    params.push(limit);
    const r = await query(
      `SELECT pel.id AS event_id, pel.pitch_id, pel.event_type, pel.old_value, pel.new_value, pel.occurred_at,
              b.name AS biz_name, b.address AS biz_address, b.city,
              p.pitch_type, p.priority, p.status, p.outreach_channel, p.dw_proximity
       FROM pitch_event_log pel
       JOIN pitches p   ON p.id = pel.pitch_id
       JOIN businesses b ON b.id = p.business_id
       ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
       ORDER BY pel.occurred_at DESC, pel.id DESC
       LIMIT $${params.length}`,
      params
    );
    // Aggregate counts for the strip
    const stats = await query(`
      SELECT
        count(*) FILTER (WHERE occurred_at >= NOW() - INTERVAL '24 hours')                                AS last_24h,
        count(*) FILTER (WHERE occurred_at >= NOW() - INTERVAL '7 days')                                  AS last_7d,
        count(DISTINCT pitch_id) FILTER (WHERE occurred_at >= NOW() - INTERVAL '7 days')                  AS pitches_touched_7d,
        count(*) FILTER (WHERE event_type = 'sent'    AND occurred_at >= NOW() - INTERVAL '7 days')       AS sent_7d,
        count(*) FILTER (WHERE event_type = 'replied' AND occurred_at >= NOW() - INTERVAL '7 days')       AS replied_7d
      FROM pitch_event_log
    `);
    res.json({ count: r.rowCount, events: r.rows, stats: stats.rows[0] });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Bulk pitch action: apply one update to many pitch IDs at once ───
// Body: { ids: number[], fields: { status?, outreach_channel?, priority?, notes? } }
// Reuses the same allowlist as PATCH /api/pitches/:id; lifecycle stamps fire too via the trigger.
app.post('/api/pitches/bulk', express.json(), async (req, res) => {
  try {
    const ids: number[] = (req.body?.ids || []).map((x: any) => parseInt(x, 10)).filter(Number.isFinite);
    if (!ids.length) return res.status(400).json({ error: 'no ids' });
    if (ids.length > 2000) return res.status(400).json({ error: 'max 2000 ids per call' });

    const allowed = ['status','outreach_channel','priority','notes','pitch_type','next_followup_at','followup_notes'];
    const fields: Record<string, any> = {};
    for (const k of allowed) if (k in (req.body?.fields || {})) fields[k] = req.body.fields[k];
    if (!Object.keys(fields).length) return res.status(400).json({ error: 'no fields' });

    const sets: string[] = [];
    const params: any[] = [];
    for (const k of Object.keys(fields)) {
      params.push(fields[k] === '' ? null : fields[k]);
      sets.push(`${k} = $${params.length}`);
    }
    if (fields.status === 'sent')     sets.push('sent_at = COALESCE(sent_at, NOW())');
    if (fields.status === 'replied')  sets.push('replied_at = COALESCE(replied_at, NOW())');
    if (fields.status === 'won' || fields.status === 'lost') sets.push('closed_at = COALESCE(closed_at, NOW())');
    if (fields.status === 'scrubbed') sets.push('scrubbed_at = NOW()');
    if (fields.status === 'approved') sets.push('approved_at = NOW()');

    params.push(ids);
    const r = await query(
      `UPDATE pitches SET ${sets.join(', ')} WHERE id = ANY($${params.length}::bigint[])`,
      params
    );
    res.json({ ok: true, updated: r.rowCount, ids: ids.length });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Markdown 1-pager: a printable / shareable per-pitch deck ─────────
// GET /api/pitches/:id/sheet.md → text/markdown rendered from pitch + business
// GET /pitch/:id (HTML wrapper for browser preview)
app.get('/api/pitches/:id/sheet.md', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).send('bad id');
    const r = await query(
      `SELECT p.*, b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
              b.phone AS biz_phone, b.website AS biz_website,
              b.raw->>'primary_naics_description' AS naics,
              b.lat, b.lng
       FROM pitches p JOIN businesses b ON b.id = p.business_id
       WHERE p.id = $1`,
      [id]
    );
    if (r.rowCount === 0) return res.status(404).send('not found');
    const p = r.rows[0];
    const proxLabel: Record<string,string> = {
      same_building:'⚡ same building', same_block:'★ same block',
      walk_2min:'2 min walk', walk_5min:'5 min walk',
      walk_10min:'10 min walk', walk_15min:'15 min walk'
    };
    const fmt = (d: any) => d ? new Date(d).toISOString().slice(0, 10) : '—';
    const md = `# ${p.biz_name}

**${p.biz_address || ''}** · ${p.city || ''} ${p.zip || ''}
${p.naics ? '*' + p.naics + '*' : ''}

| | |
|--:|:--|
| **Pitch type** | ${p.pitch_type} |
| **Status** | ${p.status} |
| **Priority** | ${p.priority} |
| **DW proximity** | ${proxLabel[p.dw_proximity] || p.dw_proximity || '—'} |
| **Outreach channel** | ${p.outreach_channel || 'unspecified'} |
| **Created** | ${fmt(p.created_at)} |
${p.sent_at    ? `| **Sent** | ${fmt(p.sent_at)} |\n` : ''}${p.replied_at ? `| **Replied** | ${fmt(p.replied_at)} |\n` : ''}${p.closed_at  ? `| **Closed** | ${fmt(p.closed_at)} |\n` : ''}
---

## Why DW fits

${p.why_dw_fits || '_Not yet researched._'}

## Observation

${p.observation || '_Not yet researched._'}

## Outreach copy

**Subject:** ${p.subject || '_(none)_'}

${p.body || '_(no body yet)_'}

${p.li_message ? `### LinkedIn DM\n\n${p.li_message}\n` : ''}
---

## Contact

| | |
|--:|:--|
| **Contact name** | ${p.contact_name || '—'} |
| **Email** | ${p.email || '—'} |
| **Phone** | ${p.phone || p.biz_phone || '—'} |
| **Website** | ${p.website || p.biz_website || '—'} |
| **LinkedIn** | ${p.linkedin || '—'} |
| **Instagram** | ${p.instagram || '—'} |

${p.reply_text ? `## Reply received\n\n> ${p.reply_text.replace(/\n/g, '\n> ')}\n\n*via ${p.reply_channel || 'unspecified'} on ${fmt(p.replied_at)}*\n\n` : ''}
${p.notes ? `## Internal notes\n\n${p.notes}\n\n` : ''}
---

*Pitch #${p.id} · biz #${p.business_id} · DW Ventura Corridor · 15442 Ventura Blvd #201 · designerwallcoverings.com*
`;
    res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
    res.send(md);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

app.get('/pitch/:id', async (req, res) => {
  const id = req.params.id;
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html>
<html><head>
<meta charset="utf-8"><title>Pitch #${id} — DW Ventura Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400&family=Inter:wght@300;400;600&display=swap');
  body { font-family: 'Inter', system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; line-height: 1.55; color: #1a1a1a; }
  h1 { font-family: 'Cormorant Garamond', serif; font-weight: 400; font-size: 38px; border-bottom: 1px solid #b89968; padding-bottom: 10px; margin-bottom: 6px; }
  h2 { font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 400; color: #6a4f2a; margin-top: 32px; }
  h3 { font-size: 14px; letter-spacing: 0.18em; text-transform: uppercase; color: #888; }
  table { border-collapse: collapse; margin: 12px 0; font-size: 13px; }
  td { padding: 4px 16px 4px 0; vertical-align: top; }
  td:first-child { color: #888; text-align: right; }
  blockquote { border-left: 3px solid #b89968; padding: 6px 18px; margin: 12px 0; color: #444; font-style: italic; background: #faf8f3; }
  hr { border: none; border-top: 1px solid #e0d8c8; margin: 24px 0; }
  code { font-family: ui-monospace, monospace; font-size: 12px; background: #faf8f3; padding: 2px 6px; }
  .toolbar { display: flex; gap: 10px; margin-bottom: 24px; font-size: 11px; letter-spacing: .18em; text-transform: uppercase; }
  .toolbar a { color: #b89968; text-decoration: none; padding: 6px 12px; border: 1px solid #d8d2c0; }
  .toolbar a:hover { border-color: #b89968; }
  @media print { .toolbar { display: none; } body { max-width: 100%; } }
</style>
</head><body>
<div class="toolbar">
  <a href="/api/pitches/${id}/sheet.md">⬇ Markdown</a>
  <a href="javascript:window.print()">🖨 Print</a>
  <a href="/pitches.html?id=${id}">Edit ↗</a>
</div>
<div id="content">Loading…</div>
<script>
fetch('/api/pitches/${id}/sheet.md').then(r => r.text()).then(md => {
  // Minimal inline markdown → HTML (no deps)
  let html = md
    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
    .replace(/^### (.+)$/gm, '<h3>$1</h3>')
    .replace(/^## (.+)$/gm, '<h2>$1</h2>')
    .replace(/^# (.+)$/gm, '<h1>$1</h1>')
    .replace(/^---$/gm, '<hr>')
    .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
    .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
    .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
    .replace(/\\n\\n/g, '</p><p>');
  // Tables: detect | rows
  html = html.replace(/((?:^\\|.*\\|\\s*\\n)+)/gm, (m) => {
    const rows = m.trim().split(/\\n/).filter(Boolean);
    const cells = rows.map(r => r.split('|').slice(1, -1).map(c => c.trim()));
    const isSep = (cs) => cs.every(c => /^:?-+:?$/.test(c));
    let out = '<table>';
    let inBody = false;
    for (let i = 0; i < cells.length; i++) {
      if (isSep(cells[i])) { inBody = true; continue; }
      const tag = inBody ? 'td' : 'th';
      out += '<tr>' + cells[i].map(c => '<' + tag + '>' + c + '</' + tag + '>').join('') + '</tr>';
    }
    return out + '</table>';
  });
  document.getElementById('content').innerHTML = '<p>' + html + '</p>';
});
</script>
</body></html>`);
});

// ─── Buildings: roster of every multi-tenant address on the corridor ──
// Powers /buildings.html. Admin-only.
app.get('/api/buildings', async (req, res) => {
  try {
    const minTenants = parseInt(String(req.query.min_tenants ?? '2'), 10) || 2;
    const sort = String(req.query.sort || 'distance'); // distance | tenants | pitched_pct | walked
    let order = 'dw_miles ASC NULLS LAST';
    if (sort === 'tenants')     order = 'tenants_total DESC';
    if (sort === 'pitched_pct') order = '(pitched::float / GREATEST(tenants_total,1)) DESC, tenants_total DESC';
    if (sort === 'walked')      order = 'walked DESC, tenants_total DESC';
    const r = await query(
      `SELECT bldg_address, city, zip, lat, lng, tenants_total, pitched, walked, replied, won, skipped, draft, unpitched, dw_miles, dominant_pitch_type
       FROM v_building_roster
       WHERE tenants_total >= $1
       ORDER BY ${order}
       LIMIT 500`,
      [minTenants]
    );
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Priority list: buildings ranked by where Steve gets the most walkable doors per minute of walking time.
// Score = unpitched_count × (1 / max(dw_miles, 0.05))   (proximity boost; closer = higher)
// Filters: walking distance only, hasn't been walked yet, has unpitched doors.
app.get('/api/buildings/priority', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
    const maxMiles = parseFloat(String(req.query.max_miles ?? '1.0'));
    const minUnpitched = parseInt(String(req.query.min_unpitched ?? '2'), 10) || 2;
    const r = await query(
      `SELECT bldg_address, city, zip, lat, lng,
              tenants_total, pitched, walked, unpitched, replied, won, dw_miles, dominant_pitch_type,
              ROUND(
                (unpitched::numeric / GREATEST(dw_miles, 0.05)::numeric)::numeric,
                1
              ) AS priority_score
       FROM v_building_roster
       WHERE dw_miles IS NOT NULL
         AND dw_miles <= $1
         AND walked = 0
         AND unpitched >= $2
       ORDER BY priority_score DESC, dw_miles ASC
       LIMIT $3`,
      [maxMiles, minUnpitched, limit]
    );
    const totalUnpitched = r.rows.reduce((a: number, x: any) => a + Number(x.unpitched || 0), 0);
    res.json({
      count: r.rowCount,
      total_unpitched_in_view: totalUnpitched,
      max_miles: maxMiles,
      min_unpitched: minUnpitched,
      rows: r.rows
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// CSV export of every multi-tenant building roster — for accountant/CRM/analyst hand-off
app.get('/api/buildings.csv', async (req, res) => {
  try {
    const minTenants = parseInt(String(req.query.min_tenants ?? '2'), 10) || 2;
    const maxMiles   = parseFloat(String(req.query.max_miles ?? '999'));
    const r = await query(
      `SELECT bldg_address, city, zip, lat, lng, dw_miles,
              tenants_total, pitched, walked, replied, won, skipped, draft, unpitched,
              dominant_pitch_type
       FROM v_building_roster
       WHERE tenants_total >= $1 AND (dw_miles IS NULL OR dw_miles <= $2)
       ORDER BY dw_miles ASC NULLS LAST, tenants_total DESC`,
      [minTenants, maxMiles]
    );
    const headers = [
      'bldg_address','city','zip','lat','lng','dw_miles',
      'tenants_total','pitched','walked','replied','won','skipped','draft','unpitched',
      'priority_score','dominant_pitch_type'
    ];
    const rows = r.rows.map((row: any) => {
      const score = row.dw_miles && row.unpitched
        ? Math.round((Number(row.unpitched) / Math.max(Number(row.dw_miles), 0.05)) * 10) / 10
        : '';
      const data = { ...row, priority_score: score };
      return headers.map(h => {
        const v = data[h];
        if (v === null || v === undefined) return '';
        const s = String(v);
        return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
      }).join(',');
    });
    const csv = [headers.join(','), ...rows].join('\n');
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename="ventura-corridor-buildings-${new Date().toISOString().slice(0,10)}.csv"`);
    res.send(csv);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/buildings/dupe-stats', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        count(*) FILTER (WHERE dec.id_a IS NULL)             AS pairs,
        count(DISTINCT d.bldg_address) FILTER (WHERE dec.id_a IS NULL) AS bldgs_with_dupes,
        count(*) FILTER (WHERE dec.decision = 'merge')       AS already_merged,
        count(*) FILTER (WHERE dec.decision = 'dismiss')     AS already_dismissed
      FROM mv_building_dupes d
      LEFT JOIN dupe_pair_decisions dec ON dec.id_a = d.id_a AND dec.id_b = d.id_b
    `);
    res.json({
      pairs: Number(r.rows[0].pairs),
      bldgs_with_dupes: Number(r.rows[0].bldgs_with_dupes),
      already_merged: Number(r.rows[0].already_merged || 0),
      already_dismissed: Number(r.rows[0].already_dismissed || 0)
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/buildings/refresh-dupes', async (_req, res) => {
  try {
    const t0 = Date.now();
    await query(`SELECT refresh_building_dupes()`);
    const r = await query(`SELECT count(*) AS pairs FROM mv_building_dupes`);
    res.json({ ok: true, pairs: Number(r.rows[0].pairs), refresh_ms: Date.now() - t0 });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Merge two businesses into one pitch.
// Picks the "primary" as the one with a pitch (or the lower id if both/neither do).
// Moves any pitch attached to the secondary onto the primary; marks secondary merged_into.
app.post('/api/buildings/dupes/merge', express.json(), async (req, res) => {
  try {
    const { id_a, id_b } = req.body || {};
    const a = parseInt(id_a, 10), b = parseInt(id_b, 10);
    if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return res.status(400).json({ error: 'bad ids' });
    const lo = Math.min(a, b), hi = Math.max(a, b);

    // Pick primary: prefer the business that has a pitch; tiebreak: lower id wins
    const aHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [a])).rowCount;
    const bHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [b])).rowCount;
    let primary: number, secondary: number;
    if (aHasPitch && !bHasPitch)      { primary = a; secondary = b; }
    else if (bHasPitch && !aHasPitch) { primary = b; secondary = a; }
    else                              { primary = Math.min(a, b); secondary = Math.max(a, b); }

    // If primary doesn't have a pitch but secondary does, move the pitch over
    const secPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [secondary]);
    if (secPitch.rowCount && !aHasPitch && !bHasPitch) {
      // shouldn't happen given checks above, but defend
    } else if (secPitch.rowCount) {
      const priPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [primary]);
      if (!priPitch.rowCount) {
        await query(`UPDATE pitches SET business_id = $1 WHERE business_id = $2`, [primary, secondary]);
      } else {
        // Both have pitches — concat the secondary's notes into the primary's, then drop the secondary's pitch
        await query(`
          UPDATE pitches p
          SET notes = CONCAT_WS(E'\\n--- merged from biz ${secondary} ---\\n', p.notes, sec.notes)
          FROM pitches sec
          WHERE p.business_id = $1 AND sec.business_id = $2
        `, [primary, secondary]);
        await query(`DELETE FROM pitches WHERE business_id = $1`, [secondary]);
      }
    }

    await query(`UPDATE businesses SET merged_into = $1, merged_at = NOW() WHERE id = $2`, [primary, secondary]);
    await query(
      `INSERT INTO dupe_pair_decisions (id_a, id_b, decision) VALUES ($1, $2, 'merge')
       ON CONFLICT (id_a, id_b) DO UPDATE SET decision='merge', decided_at=NOW()`,
      [lo, hi]
    );
    // Refresh the matview so the merged pair drops out
    await query(`SELECT refresh_building_dupes()`);

    res.json({ ok: true, primary, secondary });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Bulk merge: collapse every pair at-or-above min_confidence in a scope (one building, or corridor-wide).
app.post('/api/buildings/dupes/bulk-merge', express.json(), async (req, res) => {
  try {
    const minConf = Math.max(0, Math.min(1, parseFloat(req.body?.min_confidence ?? '1.0')));
    const bldg = req.body?.bldg_address ? String(req.body.bldg_address) : null;
    const dryRun = req.body?.dry_run === true;

    const pairs = await query(
      `SELECT d.id_a, d.id_b, d.bldg_address, d.match_type, d.confidence
       FROM mv_building_dupes d
       LEFT JOIN dupe_pair_decisions dec ON dec.id_a = d.id_a AND dec.id_b = d.id_b
       WHERE dec.id_a IS NULL
         AND d.confidence >= $1
         ${bldg ? 'AND d.bldg_address = $2' : ''}
       ORDER BY d.confidence DESC, d.id_a ASC`,
      bldg ? [minConf, bldg] : [minConf]
    );

    if (dryRun) {
      return res.json({ dry_run: true, eligible_pairs: pairs.rowCount, scope: bldg || 'corridor-wide', min_confidence: minConf });
    }

    let merged = 0, skipped = 0;
    const mergedSet = new Set<number>(); // ids that have already been absorbed; skip pairs touching them

    for (const p of pairs.rows) {
      const a = Number(p.id_a), b = Number(p.id_b);
      if (mergedSet.has(a) || mergedSet.has(b)) { skipped++; continue; }

      const aHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [a])).rowCount;
      const bHasPitch = (await query(`SELECT 1 FROM pitches WHERE business_id = $1`, [b])).rowCount;
      let primary: number, secondary: number;
      if (aHasPitch && !bHasPitch)      { primary = a; secondary = b; }
      else if (bHasPitch && !aHasPitch) { primary = b; secondary = a; }
      else                              { primary = Math.min(a, b); secondary = Math.max(a, b); }

      const secPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [secondary]);
      if (secPitch.rowCount) {
        const priPitch = await query(`SELECT id FROM pitches WHERE business_id = $1`, [primary]);
        if (!priPitch.rowCount) {
          await query(`UPDATE pitches SET business_id = $1 WHERE business_id = $2`, [primary, secondary]);
        } else {
          await query(`
            UPDATE pitches p SET notes = CONCAT_WS(E'\\n--- merged from biz ${secondary} ---\\n', p.notes, sec.notes)
            FROM pitches sec WHERE p.business_id = $1 AND sec.business_id = $2
          `, [primary, secondary]);
          await query(`DELETE FROM pitches WHERE business_id = $1`, [secondary]);
        }
      }

      await query(`UPDATE businesses SET merged_into = $1, merged_at = NOW() WHERE id = $2 AND merged_into IS NULL`, [primary, secondary]);
      const lo = Math.min(a, b), hi = Math.max(a, b);
      await query(
        `INSERT INTO dupe_pair_decisions (id_a, id_b, decision) VALUES ($1, $2, 'merge')
         ON CONFLICT (id_a, id_b) DO UPDATE SET decision='merge', decided_at=NOW()`,
        [lo, hi]
      );
      mergedSet.add(secondary);
      merged++;
    }

    await query(`SELECT refresh_building_dupes()`);
    res.json({ ok: true, merged, skipped_chains: skipped, scope: bldg || 'corridor-wide', min_confidence: minConf });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Dismiss a pair as a false positive — they stay separate, just stop suggesting them as dupes
app.post('/api/buildings/dupes/dismiss', express.json(), async (req, res) => {
  try {
    const { id_a, id_b } = req.body || {};
    const a = parseInt(id_a, 10), b = parseInt(id_b, 10);
    if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return res.status(400).json({ error: 'bad ids' });
    const lo = Math.min(a, b), hi = Math.max(a, b);
    await query(
      `INSERT INTO dupe_pair_decisions (id_a, id_b, decision) VALUES ($1, $2, 'dismiss')
       ON CONFLICT (id_a, id_b) DO UPDATE SET decision='dismiss', decided_at=NOW()`,
      [lo, hi]
    );
    res.json({ ok: true, decision: 'dismiss' });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Pitch-type distribution for one building (powers the heatmap bar in /buildings.html)
app.get('/api/buildings/:bldg/pitch-types', async (req, res) => {
  try {
    const bldg = decodeURIComponent(req.params.bldg);
    const r = await query(
      `SELECT
         COALESCE(p.pitch_type, '(no pitch)') AS pitch_type,
         count(*) AS n
       FROM v_building_canonical bc
       LEFT JOIN pitches p ON p.business_id = bc.id
       WHERE bc.bldg_address = $1
       GROUP BY p.pitch_type
       ORDER BY n DESC`,
      [bldg]
    );
    res.json({ bldg_address: bldg, total: r.rows.reduce((a, x) => a + Number(x.n), 0), rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/buildings/:bldg/dupes', async (req, res) => {
  try {
    const bldg = decodeURIComponent(req.params.bldg);
    const r = await query(
      `SELECT d.id_a, d.id_b, d.name_a, d.name_b, d.suite_a, d.suite_b, d.match_type, d.confidence
       FROM mv_building_dupes d
       LEFT JOIN dupe_pair_decisions dec
         ON dec.id_a = d.id_a AND dec.id_b = d.id_b
       WHERE d.bldg_address = $1
         AND dec.id_a IS NULL  -- exclude already-merged + already-dismissed pairs
       ORDER BY d.confidence DESC, d.name_a ASC`,
      [bldg]
    );
    res.json({ bldg_address: bldg, count: r.rowCount, pairs: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/buildings/:bldg', async (req, res) => {
  try {
    const bldg = decodeURIComponent(req.params.bldg);
    if (!bldg || bldg.length < 3) return res.status(400).json({ error: 'bad building' });
    const r = await query(
      `SELECT bc.id AS business_id, bc.name, bc.address, bc.suite, bc.city, bc.zip,
              bc.lat, bc.lng, bc.phone, bc.website, bc.category, bc.naics,
              p.id AS pitch_id, p.pitch_type, p.priority, p.status, p.outreach_channel,
              p.dw_proximity, p.sent_at, p.replied_at, p.closed_at, p.won_value_usd,
              p.li_message, p.contact_name, p.notes,
              COALESCE(n.news_count, 0)::int AS news_count
       FROM v_building_canonical bc
       LEFT JOIN pitches p ON p.business_id = bc.id
       LEFT JOIN LATERAL (
         SELECT COUNT(*) AS news_count FROM news_items WHERE business_id = bc.id
       ) n ON TRUE
       WHERE bc.bldg_address = $1
       ORDER BY
         CASE WHEN p.id IS NULL THEN 1 ELSE 0 END,
         p.priority ASC NULLS LAST,
         bc.suite ASC, bc.name ASC`,
      [bldg]
    );
    res.json({ bldg_address: bldg, count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Postcard preview: render the front/back HTML with a real pitch ──
// The /postcards.html viewer iframes these endpoints so Steve sees the
// final postcard rendered from real PG data before he uploads templates to Lob.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';

function renderTemplate(tplName: 'postcard-template-front.html' | 'postcard-template-back.html', vars: Record<string, string>) {
  const __dirname = dirname(fileURLToPath(import.meta.url));
  const root = join(__dirname, '..', '..', 'public');
  const tpl = readFileSync(join(root, tplName), 'utf-8');
  return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, key) => vars[key] ?? '');
}

app.get('/api/pitches/:id/postcard-preview/:side(front|back).html', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const side = req.params.side as 'front' | 'back';
    if (!Number.isFinite(id)) return res.status(400).send('bad id');
    const r = await query(
      `SELECT p.id, b.name, p.contact_name, b.address, b.city, b.zip,
              p.dw_proximity, p.pitch_type, p.subject, p.body
       FROM pitches p JOIN businesses b ON b.id = p.business_id
       WHERE p.id = $1`,
      [id]
    );
    if (r.rowCount === 0) return res.status(404).send('not found');
    const row = r.rows[0];
    const vars: Record<string, string> = {
      'to.name': row.contact_name || row.name || 'Neighbor',
      'to.company': row.contact_name ? row.name : '',
      'to.address_line1': row.address || '',
      'to.address_city': row.city || '',
      'to.address_state': 'CA',
      'to.address_zip': row.zip || '',
      'merge.dw_proximity': (row.dw_proximity || '').replace(/_/g, ' '),
      'merge.pitch_type': row.pitch_type || '',
      'merge.subject': row.subject || '',
      'merge.body':
        row.body
        || `Hi — Steve from Designer Wallcoverings, suite 201 right here at 15442 Ventura. We carry the lines you see in shelter magazines (Phillip Jeffries, Cole & Son, Schumacher, Mind the Gap). Free trade samples, no minimum, no pressure — just a sample drop and a coffee.`,
    };
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(renderTemplate(`postcard-template-${side}.html` as any, vars));
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// ─── Timeline / audit log: every lifecycle event for one pitch ──────
app.get('/api/pitches/:id/timeline', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
    const r = await query(
      `SELECT id, event_type, old_value, new_value, occurred_at
       FROM pitch_event_log
       WHERE pitch_id = $1
       ORDER BY occurred_at DESC, id DESC
       LIMIT 200`,
      [id]
    );
    res.json({ count: r.rowCount, events: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── CSV export of all responses (for accountant / external CRM) ────
app.get('/api/responses.csv', async (_req, res) => {
  try {
    const r = await query(`
      SELECT p.id, b.name, b.address, b.city, b.zip, p.pitch_type, p.status,
             p.outreach_channel, p.reply_channel,
             to_char(p.sent_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM-DD HH24:MI') AS sent_pt,
             to_char(p.replied_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM-DD HH24:MI') AS replied_pt,
             to_char(p.closed_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM-DD HH24:MI') AS closed_pt,
             p.won_value_usd, p.lost_reason,
             p.contact_name, p.email, p.phone, p.linkedin,
             to_char(p.next_followup_at, 'YYYY-MM-DD') AS next_followup,
             p.followup_notes,
             REPLACE(REPLACE(COALESCE(p.reply_text, ''), CHR(10), ' '), CHR(13), ' ') AS reply_text,
             REPLACE(REPLACE(COALESCE(p.notes, ''), CHR(10), ' '), CHR(13), ' ') AS notes
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      WHERE p.replied_at IS NOT NULL OR p.status IN ('won','lost')
      ORDER BY COALESCE(p.closed_at, p.replied_at) DESC NULLS LAST
    `);
    const headers = [
      'pitch_id','business','address','city','zip','pitch_type','status',
      'outreach_channel','reply_channel','sent_pt','replied_pt','closed_pt',
      'won_value_usd','lost_reason','contact_name','email','phone','linkedin',
      'next_followup','followup_notes','reply_text','notes'
    ];
    const rows = r.rows.map((row: any) => headers.map(h => {
      const k = h === 'pitch_id' ? 'id' : h === 'business' ? 'name' : h;
      const v = row[k];
      if (v === null || v === undefined) return '';
      const s = String(v);
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    }).join(','));
    const csv = [headers.join(','), ...rows].join('\n');
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename="ventura-corridor-responses-${new Date().toISOString().slice(0,10)}.csv"`);
    res.send(csv);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Data-freshness signal: when was the last BTRC ingest, are we stale? ──
app.get('/api/data-freshness', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        count(*) FILTER (WHERE source LIKE '%btrc%' OR source = 'la_btrc')                AS btrc_total,
        MAX(first_seen_at) FILTER (WHERE source LIKE '%btrc%' OR source = 'la_btrc')      AS last_btrc_ingest,
        count(*)                                                                          AS all_total,
        MAX(last_seen_at)                                                                 AS last_any_seen
      FROM businesses
    `);
    const row = r.rows[0];
    const lastIngest = row.last_btrc_ingest ? new Date(row.last_btrc_ingest) : null;
    const daysSince = lastIngest ? Math.floor((Date.now() - lastIngest.getTime()) / 86_400_000) : null;
    let status: 'fresh' | 'stale' | 'very_stale' | 'never' = 'never';
    if (daysSince !== null) {
      if (daysSince <= 7)       status = 'fresh';
      else if (daysSince <= 30) status = 'stale';
      else                      status = 'very_stale';
    }
    res.json({
      btrc_total: Number(row.btrc_total),
      last_btrc_ingest: row.last_btrc_ingest,
      days_since_ingest: daysSince,
      status,
      all_total: Number(row.all_total),
      source_url: 'https://data.lacity.org/resource/6rrh-rzua.json',
      ingest_command: 'npm run ingest:la_btrc'
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Stable per-day feature pick: use day-of-year as seed so it's deterministic for "today"
app.get('/api/magazine/stale', async (req, res) => {
  try {
    const days = Math.max(parseInt(String(req.query.days ?? '7'), 10) || 7, 1);
    const r = await query(
      `SELECT mf.id, mf.headline, mf.category_tag, mf.generated_at,
              FLOOR(EXTRACT(EPOCH FROM (NOW() - mf.generated_at))/86400)::int AS days_old,
              b.name AS biz_name
       FROM magazine_features mf
       JOIN businesses b ON b.id = mf.business_id
       WHERE mf.status = 'draft'
         AND mf.generated_at < NOW() - ($1 || ' days')::interval
       ORDER BY mf.generated_at ASC
       LIMIT 30`,
      [days]
    );
    res.json({ count: r.rowCount, days_threshold: days, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Stable per-week pick: rotates weekly, prefers published features for editorial polish
app.get('/api/magazine/feature-of-the-week', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             b.name AS biz_name, b.address AS biz_address, b.city
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.editorial IS NOT NULL AND length(mf.editorial) > 300
        AND mf.status IN ('published', 'reviewed')
      ORDER BY (mf.id * (EXTRACT(WEEK FROM CURRENT_DATE)::int + 7)) % (SELECT GREATEST(count(*), 1) FROM magazine_features)
      LIMIT 1
    `);
    if (r.rowCount === 0) return res.json({ feature: null });
    res.json({ feature: r.rows[0] });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/magazine/feature-of-the-day', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             b.name AS biz_name, b.address AS biz_address, b.city
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.editorial IS NOT NULL AND length(mf.editorial) > 200
      ORDER BY (mf.id * (EXTRACT(DOY FROM CURRENT_DATE)::int + 1)) % (SELECT GREATEST(count(*), 1) FROM magazine_features)
      LIMIT 1
    `);
    if (r.rowCount === 0) return res.json({ feature: null });
    res.json({ feature: r.rows[0] });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Feature of the month — returns the cover-pick if set; otherwise the same heuristic as auto-cover.
app.get('/api/magazine/feature-of-the-month', async (_req, res) => {
  try {
    const month = new Date().toISOString().slice(0, 7);
    // Manual cover pick wins.
    let r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             i.cover_caption, i.cover_kicker, i.cover_image_path,
             b.name AS biz_name, b.address AS biz_address, b.city
      FROM magazine_issues i
      JOIN magazine_features mf ON mf.id = i.cover_feature_id
      JOIN businesses b ON b.id = mf.business_id
      WHERE i.issue_month = $1 AND mf.status = 'published'
    `, [month]);
    if (r.rowCount > 0) return res.json({ feature: r.rows[0], source: 'cover_pick', month });

    // Fallback: top-tap × 3 + views, this month, same heuristic as auto_cover.ts.
    r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             b.name AS biz_name, b.address AS biz_address, b.city
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.status = 'published' AND length(mf.editorial) > 240
        AND to_char(mf.published_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM') = $1
      ORDER BY (COALESCE(mf.taps,0) * 3 + COALESCE(mf.views,0)) DESC, mf.published_at DESC
      LIMIT 1
    `, [month]);
    if (r.rowCount > 0) return res.json({ feature: r.rows[0], source: 'auto_pick', month });

    return res.json({ feature: null, source: 'none', month });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Unified daily payload — what an IG-autopost / morning-email / standup-audio job needs in one call.
app.get('/api/magazine/today', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             mf.published_at, mf.views, mf.taps,
             b.name AS biz_name, b.address AS biz_address, b.city, b.zip
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.editorial IS NOT NULL AND length(mf.editorial) > 200
        AND mf.status IN ('published','reviewed')
      ORDER BY (mf.id * (EXTRACT(DOY FROM CURRENT_DATE)::int + 1)) % (SELECT GREATEST(count(*), 1) FROM magazine_features)
      LIMIT 1
    `);
    if (r.rowCount === 0) return res.json({ feature: null });
    const f = r.rows[0];
    const tags = ['#TheCorridor', '#VenturaBoulevard', '#LosAngeles'];
    if (f.category_tag) tags.push('#' + String(f.category_tag).replace(/[^a-z0-9]/gi, ''));
    if (f.city) tags.push('#' + String(f.city).replace(/\s+/g, ''));
    const caption =
      `${f.headline}\n\n` +
      (f.subhead ? `${f.subhead}\n\n` : '') +
      `${(f.editorial as string).slice(0, 380).replace(/\s+\S*$/, '')}…\n\n` +
      `Read the full feature → /magazine/${f.id}\n\n` +
      tags.join(' ');
    res.json({
      date: new Date().toISOString().slice(0, 10),
      feature: f,
      permalink: `/magazine/${f.id}`,
      share_card: `/share/${f.id}`,
      audio: `/api/magazine/${f.id}/audio.m4a`,
      tags,
      caption,
      caption_chars: caption.length,
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Random published feature — for fleet-tv-style rotators or sidebar widgets
app.get('/api/magazine/random', async (req, res) => {
  try {
    const cat = String(req.query.cat || '');
    const where: string[] = [`status = 'published'`, `length(editorial) > 100`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`category_tag = $${params.length}`); }
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              b.name AS biz_name, b.address AS biz_address, b.city
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY random() LIMIT 1`,
      params
    );
    if (r.rowCount === 0) return res.json({ feature: null });
    res.json({ feature: r.rows[0], permalink: `/magazine/${r.rows[0].id}` });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Editorial similarity — finds features whose bodies most resemble the given feature.
// Uses pg_trgm word_similarity over the first 480 chars (avoids huge cross-products).
// Use case: detect when qwen3 reuses similar sentence structure across features
// (deeper signal than exact-duplicate or trigram detection).
app.get('/api/magazine/similar/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const limit = Math.min(20, Math.max(1, parseInt(String(req.query.limit || 5), 10) || 5));
    if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
    const seed = await query(`SELECT editorial, headline FROM magazine_features WHERE id = $1`, [id]);
    if (seed.rowCount === 0) return res.status(404).json({ error: 'feature not found' });
    const seedText = String(seed.rows[0].editorial || '').slice(0, 480);
    if (!seedText) return res.json({ id, neighbors: [] });

    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.category_tag, mf.status,
             b.name AS biz_name,
             round(similarity(LEFT(mf.editorial, 480), $2)::numeric, 3) AS similarity
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.id != $1
        AND mf.editorial IS NOT NULL
        AND length(mf.editorial) > 100
      ORDER BY LEFT(mf.editorial, 480) <-> $2 ASC
      LIMIT $3
    `, [id, seedText, limit]);

    res.json({
      id,
      seed_headline: seed.rows[0].headline,
      neighbors: r.rows.map((n: any) => ({ ...n, permalink: `/magazine/${n.id}` })),
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Calendar — features generated/published per day for the last N days.
// Powers /calendar.html GitHub-style heatmap.
app.get('/api/magazine/calendar', async (req, res) => {
  try {
    const days = Math.min(365, Math.max(7, parseInt(String(req.query.days || 90), 10) || 90));
    const r = await query(`
      SELECT to_char(date_trunc('day', generated_at AT TIME ZONE 'America/Los_Angeles'), 'YYYY-MM-DD') AS day,
             count(*) AS gen,
             count(*) FILTER (WHERE status='published') AS pub,
             count(*) FILTER (WHERE status='draft') AS draft
      FROM magazine_features
      WHERE generated_at > NOW() - (INTERVAL '1 day' * $1)
      GROUP BY 1 ORDER BY 1
    `, [days]);
    res.json({ days, count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Headline-phrase frequency — finds 3-word phrases ("trigrams") that recur across
// headlines. Cousin of /api/magazine/duplicates: catches templating BEFORE it
// becomes an exact-match duplicate. Surfaces the patterns qwen3 reaches for.
app.get('/api/magazine/headline-stems', async (_req, res) => {
  try {
    const r = await query(`SELECT headline FROM magazine_features WHERE headline IS NOT NULL`);
    const STOP = new Set('a an the and or in of to for on at with by from is are be was were has have had this that these those'.split(/\s+/));
    const counts: Record<string, number> = {};
    const examples: Record<string, string[]> = {};
    for (const row of r.rows) {
      const h = String(row.headline);
      const tokens = h.toLowerCase().replace(/[^a-z\s']/g, ' ').split(/\s+/).filter(t => t && !STOP.has(t) && t.length > 2);
      for (let i = 0; i + 2 < tokens.length; i++) {
        const tri = `${tokens[i]} ${tokens[i+1]} ${tokens[i+2]}`;
        counts[tri] = (counts[tri] || 0) + 1;
        if (!examples[tri]) examples[tri] = [];
        if (examples[tri].length < 3 && !examples[tri].includes(h)) examples[tri].push(h);
      }
    }
    const ranked = Object.entries(counts)
      .filter(([, n]) => n >= 2)
      .sort((a, b) => b[1] - a[1])
      .slice(0, 30)
      .map(([phrase, count]) => ({ phrase, count, examples: examples[phrase] }));
    res.json({ count: ranked.length, total_headlines: r.rowCount, stems: ranked });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Duplicate-headline detector — when qwen3 converges to the same headline pattern
// (e.g. "Precision Care in Encino's Heart" 3×), the editorial assistant is
// templating. Surfacing duplicates lets Steve regen the runner-ups for variety.
app.get('/api/magazine/duplicates', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.headline,
             count(*) AS dup_count,
             array_agg(json_build_object(
               'id', mf.id,
               'biz_name', b.name,
               'category_tag', mf.category_tag,
               'status', mf.status,
               'editorial_len', length(mf.editorial),
               'generated_at', mf.generated_at
             ) ORDER BY mf.generated_at DESC) AS features
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.headline IS NOT NULL
      GROUP BY mf.headline
      HAVING count(*) > 1
      ORDER BY count(*) DESC, mf.headline ASC
    `);
    const totalAffected = r.rows.reduce((acc: number, row: any) => acc + Number(row.dup_count), 0);
    res.json({ groups: r.rowCount, total_affected: totalAffected, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Draft-pick-of-the-night — strongest unpublished draft from the last 24h,
// scored by editorial length × pull-quote-presence × subhead × ad-signal × random tiebreak.
// Used to seed the "Publish next" pill on /today.html and the morning email.
app.get('/api/magazine/draft-pick-of-the-night', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
             mf.generated_at, length(mf.editorial) AS editorial_len,
             b.name AS biz_name, b.address AS biz_address, b.city,
             COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count,
             (length(mf.editorial) +
              CASE WHEN mf.pull_quote IS NOT NULL THEN 100 ELSE 0 END +
              CASE WHEN mf.subhead    IS NOT NULL THEN  50 ELSE 0 END +
              COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) * 30) AS score
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
      WHERE mf.status = 'draft'
        AND mf.generated_at > NOW() - INTERVAL '24 hours'
        AND length(mf.editorial) > 240
      ORDER BY score DESC, mf.generated_at DESC
      LIMIT 1
    `);
    if (r.rowCount === 0) return res.json({ pick: null });
    const f = r.rows[0];
    res.json({
      pick: f,
      permalink: `/magazine/${f.id}`,
      ad_pack_url: `/magazine/${f.id}/ad-pack.html`,
      review_url: `/magazine.html?status=draft#${f.id}`,
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Quality-flag audit — surfaces features with fixable issues for the curation queue.
// Each flag is independent (a feature can have multiple). Used by /magazine.html
// for a "🔧 quality" chip + by the standup script to count cleanup opportunities.
app.get('/api/magazine/quality-flags', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.status, mf.category_tag,
             length(mf.editorial) AS editorial_len,
             mf.pull_quote IS NULL OR length(mf.pull_quote) < 16 AS no_pull_quote,
             mf.subhead IS NULL OR length(mf.subhead) < 16 AS no_subhead,
             length(mf.editorial) < 240 AS thin,
             (mf.status='draft' AND mf.generated_at < NOW() - INTERVAL '7 days') AS stale_draft,
             (mf.status='reviewed' AND mf.reviewed_at < NOW() - INTERVAL '4 days') AS stale_reviewed,
             (mf.status='published' AND mf.published_at IS NULL) AS published_no_timestamp,
             (mf.category_tag IS NULL OR mf.category_tag IN ('misc','uncategorized','feature')) AS no_category,
             b.name AS biz_name
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE
        length(mf.editorial) < 240
        OR mf.pull_quote IS NULL OR length(mf.pull_quote) < 16
        OR mf.subhead IS NULL OR length(mf.subhead) < 16
        OR (mf.status='draft' AND mf.generated_at < NOW() - INTERVAL '7 days')
        OR (mf.status='reviewed' AND mf.reviewed_at < NOW() - INTERVAL '4 days')
        OR (mf.status='published' AND mf.published_at IS NULL)
        OR mf.category_tag IS NULL OR mf.category_tag IN ('misc','uncategorized','feature')
      ORDER BY mf.generated_at DESC
      LIMIT 200
    `);
    const summary = {
      total_flagged: r.rowCount,
      thin: r.rows.filter((x: any) => x.thin).length,
      no_pull_quote: r.rows.filter((x: any) => x.no_pull_quote).length,
      no_subhead: r.rows.filter((x: any) => x.no_subhead).length,
      stale_draft: r.rows.filter((x: any) => x.stale_draft).length,
      stale_reviewed: r.rows.filter((x: any) => x.stale_reviewed).length,
      no_category: r.rows.filter((x: any) => x.no_category).length,
      published_no_timestamp: r.rows.filter((x: any) => x.published_no_timestamp).length,
    };
    res.json({ count: r.rowCount, summary, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Coverage report — what % of corridor businesses have a feature yet, broken down
// by vertical and city. Used for /coverage.html admin viewer + standup metrics.
app.get('/api/coverage', async (_req, res) => {
  try {
    const totals = await query(`
      SELECT
        (SELECT count(*) FROM businesses) AS total_biz,
        (SELECT count(DISTINCT business_id) FROM magazine_features) AS biz_with_features,
        (SELECT count(*) FROM magazine_features) AS total_features,
        (SELECT count(*) FROM magazine_features WHERE status='draft') AS draft,
        (SELECT count(*) FROM magazine_features WHERE status='reviewed') AS reviewed,
        (SELECT count(*) FROM magazine_features WHERE status='published') AS published,
        (SELECT count(*) FROM magazine_features WHERE status='spiked') AS spiked
    `);
    const byVertical = await query(`
      WITH
        biz_by_naics AS (
          SELECT b.id,
                 lower(coalesce(b.raw->>'primary_naics_description', 'unclassified')) AS naics
          FROM businesses b
        ),
        feat_by_biz AS (
          SELECT business_id FROM magazine_features
        )
      SELECT bn.naics AS bucket,
             count(*) AS total,
             count(*) FILTER (WHERE bn.id IN (SELECT business_id FROM feat_by_biz)) AS covered
      FROM biz_by_naics bn
      GROUP BY bn.naics
      HAVING count(*) >= 25
      ORDER BY total DESC LIMIT 30
    `);
    const byCity = await query(`
      SELECT coalesce(city, 'unknown') AS city,
             count(*) AS total,
             count(*) FILTER (WHERE EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)) AS covered
      FROM businesses b
      GROUP BY coalesce(city, 'unknown')
      HAVING count(*) >= 25
      ORDER BY total DESC LIMIT 20
    `);
    const recent7d = await query(`
      SELECT to_char(date_trunc('day', generated_at AT TIME ZONE 'America/Los_Angeles'), 'YYYY-MM-DD') AS day,
             count(*) AS gen,
             count(*) FILTER (WHERE status='published') AS published
      FROM magazine_features
      WHERE generated_at > NOW() - INTERVAL '14 days'
      GROUP BY 1 ORDER BY 1
    `);
    const t = totals.rows[0];
    res.json({
      totals: t,
      coverage_pct: t.total_biz ? +(Number(t.biz_with_features) / Number(t.total_biz) * 100).toFixed(2) : 0,
      by_vertical: byVertical.rows,
      by_city: byCity.rows,
      recent_14d: recent7d.rows,
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Gen queue — what the watchdog will likely tackle next. Same ORDER BY logic as
// generate_features.ts, surfaced for visibility (no random() so the order is stable
// across calls; the actual gen job re-shuffles ties via random()).
app.get('/api/magazine/gen-queue', async (req, res) => {
  try {
    const limit = Math.min(50, Math.max(1, parseInt(String(req.query.limit || 20), 10) || 20));
    const r = await query(
      `SELECT b.id, b.name, b.address, b.city, b.zip,
              b.raw->>'primary_naics_description' AS naics,
              p.pitch_type,
              COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count
       FROM businesses b
       LEFT JOIN pitches p ON p.business_id = b.id
       LEFT JOIN business_enrichment be ON be.business_id = b.id
       WHERE b.on_corridor
         AND b.merged_into IS NULL
         AND b.address IS NOT NULL
         AND b.name NOT ILIKE '%LLC' AND b.name NOT ILIKE '%INC' AND b.name NOT ILIKE '%CORP'
         AND length(b.name) BETWEEN 4 AND 60
         AND NOT EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)
       ORDER BY
         (COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) > 0) DESC,
         (p.pitch_type IS NOT NULL) DESC,
         b.id ASC
       LIMIT $1`,
      [limit]
    );
    res.json({ count: r.rowCount, queue: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Coverage history — feeds the "growth over time" line on /coverage.html.
app.get('/api/coverage/history', async (_req, res) => {
  try {
    const r = await query(`
      SELECT id, taken_at, total_biz, biz_with_features, total_features,
             drafts, reviewed, published, feedback_total, inquiries_total
      FROM coverage_snapshots ORDER BY taken_at ASC LIMIT 365
    `);
    res.json({ count: r.rowCount, snapshots: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Magazine activity feed — unified stream across feature gen/publish/cover + feedback + inquiries.
// Powers a "what just happened" widget on /today.html and the daily-standup script.
app.get('/api/magazine/activity', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '40'), 10) || 40, 200);
    // UNION across event sources, tagged with a 'kind' for client-side rendering.
    const r = await query(`
      SELECT * FROM (
        SELECT 'feature.generated' AS kind, mf.id AS ref_id,
               coalesce(mf.headline, b.name) AS label, b.name AS biz_name,
               mf.category_tag, NULL::text AS extra,
               mf.generated_at AS at
        FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
        WHERE mf.generated_at > NOW() - INTERVAL '14 days'

        UNION ALL
        SELECT 'feature.published' AS kind, mf.id, coalesce(mf.headline, b.name), b.name,
               mf.category_tag, NULL, mf.published_at
        FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
        WHERE mf.published_at > NOW() - INTERVAL '14 days'

        UNION ALL
        SELECT 'feature.reviewed' AS kind, mf.id, coalesce(mf.headline, b.name), b.name,
               mf.category_tag, NULL, mf.reviewed_at
        FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
        WHERE mf.reviewed_at > NOW() - INTERVAL '14 days'

        UNION ALL
        SELECT 'cover.set' AS kind, i.cover_feature_id, mf.headline, b.name,
               mf.category_tag, i.issue_month, i.cover_set_at
        FROM magazine_issues i
        LEFT JOIN magazine_features mf ON mf.id = i.cover_feature_id
        LEFT JOIN businesses b ON b.id = mf.business_id
        WHERE i.cover_set_at > NOW() - INTERVAL '30 days'

        UNION ALL
        SELECT ('feedback.' || rf.kind) AS kind, rf.feature_id, mf.headline, b.name,
               mf.category_tag, rf.body, rf.received_at
        FROM reader_feedback rf
        LEFT JOIN magazine_features mf ON mf.id = rf.feature_id
        LEFT JOIN businesses b ON b.id = mf.business_id
        WHERE rf.received_at > NOW() - INTERVAL '14 days'

        UNION ALL
        SELECT 'inquiry.received' AS kind, si.feature_id, si.biz_name, si.biz_name,
               si.tier, si.contact_email, si.received_at
        FROM sponsor_inquiries si
        WHERE si.received_at > NOW() - INTERVAL '30 days'
      ) ev
      WHERE ev.at IS NOT NULL
      ORDER BY ev.at DESC
      LIMIT $1
    `, [limit]);

    const stats = await query(`
      SELECT
        (SELECT count(*) FROM magazine_features WHERE generated_at > NOW() - INTERVAL '24 hours') AS gen_24h,
        (SELECT count(*) FROM magazine_features WHERE published_at > NOW() - INTERVAL '24 hours') AS pub_24h,
        (SELECT count(*) FROM reader_feedback WHERE received_at > NOW() - INTERVAL '24 hours') AS fb_24h,
        (SELECT count(*) FROM sponsor_inquiries WHERE received_at > NOW() - INTERVAL '24 hours') AS inq_24h
    `);
    res.json({ count: r.rowCount, events: r.rows, stats: stats.rows[0] });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Lottery — N random published features. Useful for "5 you may have missed" rails,
// IG-autopost backup picks, and the fleet-tv rotator. Optional ?cat= filter; ?n= clamps to 1..50.
app.get('/api/magazine/lottery', async (req, res) => {
  try {
    const cat = String(req.query.cat || '').trim() || null;
    const n = Math.min(50, Math.max(1, parseInt(String(req.query.n || 5), 10) || 5));
    const where: string[] = [`mf.status = 'published'`, `length(mf.editorial) > 200`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
    params.push(n);
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.pull_quote, mf.category_tag,
              mf.published_at, COALESCE(mf.taps, 0) AS taps, COALESCE(mf.views, 0) AS views,
              b.name AS biz_name, b.address AS biz_address, b.city
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY random() LIMIT $${params.length}`,
      params
    );
    res.json({
      count: r.rowCount,
      cat,
      features: r.rows.map((f: any) => ({
        ...f,
        permalink: `/magazine/${f.id}`,
        share_card: `/share/${f.id}`,
      })),
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Per-feature TTS audio — /api/magazine/:id/audio.aiff
// Uses macOS `say` command. Caches by editorial-hash so we don't re-render every visit.
import { existsSync as _fs_exists, mkdirSync as _fs_mkdir, statSync as _fs_stat, createReadStream as _fs_stream } from 'node:fs';
import { spawnSync as _spawn_sync } from 'node:child_process';
import { createHash as _crypto_hash } from 'node:crypto';

async function renderFeatureAudio(id: number, format: 'aiff' | 'm4a' | 'mp3'): Promise<{ filepath: string; mime: string } | { error: string; status: number }> {
  const r = await query(
    `SELECT mf.headline, mf.subhead, mf.editorial, mf.pull_quote, b.name AS biz_name
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`,
    [id]
  );
  if (r.rowCount === 0) return { error: 'not found', status: 404 };
  const f = r.rows[0];
  const text = [
    'From The Corridor.',
    f.headline || f.biz_name,
    f.subhead || '',
    f.editorial || '',
    f.pull_quote ? `Quote: ${f.pull_quote}` : '',
    `End of feature.`
  ].filter(Boolean).join('\n\n');

  const audioDir = path.resolve(__dirname, '../../data/magazine-audio');
  if (!_fs_exists(audioDir)) _fs_mkdir(audioDir, { recursive: true });
  const hash = _crypto_hash('sha1').update(text).digest('hex').slice(0, 12);
  const aiffPath = path.join(audioDir, `${id}-${hash}.aiff`);
  const outPath  = path.join(audioDir, `${id}-${hash}.${format}`);

  if (!_fs_exists(aiffPath)) {
    const voice = process.env.SAY_VOICE || 'Samantha';
    const result = _spawn_sync('say', ['-v', voice, '-r', '180', '-o', aiffPath, text], { timeout: 60_000 });
    if (result.status !== 0 || !_fs_exists(aiffPath)) {
      return { error: 'say failed: ' + (result.stderr?.toString() || ''), status: 500 };
    }
  }

  if (format === 'aiff') return { filepath: aiffPath, mime: 'audio/aiff' };

  if (!_fs_exists(outPath)) {
    // macOS afconvert: AIFF → AAC m4a (or LAME for mp3)
    if (format === 'm4a') {
      const result = _spawn_sync('afconvert', ['-f', 'm4af', '-d', 'aac', '-b', '64000', aiffPath, outPath], { timeout: 60_000 });
      if (result.status !== 0 || !_fs_exists(outPath)) {
        return { error: 'afconvert failed: ' + (result.stderr?.toString() || ''), status: 500 };
      }
    } else if (format === 'mp3') {
      // try lame if available, else fallback to m4a-as-mp3
      const lameResult = _spawn_sync('lame', ['-h', '-b', '64', aiffPath, outPath], { timeout: 60_000 });
      if (lameResult.status !== 0 || !_fs_exists(outPath)) {
        return { error: 'lame not available; use /audio.m4a or /audio.aiff', status: 501 };
      }
    }
  }
  return { filepath: outPath, mime: format === 'm4a' ? 'audio/mp4' : 'audio/mpeg' };
}

app.get('/api/magazine/:id/audio.:fmt(aiff|m4a|mp3)', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).send('bad id');
    const fmt = (req.params as any).fmt as 'aiff' | 'm4a' | 'mp3';
    const r = await renderFeatureAudio(id, fmt);
    if ('error' in r) return res.status(r.status).send(r.error);
    const stat = _fs_stat(r.filepath);
    res.setHeader('Content-Type', r.mime);
    res.setHeader('Content-Length', String(stat.size));
    res.setHeader('Cache-Control', 'public, max-age=86400');
    _fs_stream(r.filepath).pipe(res);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// Outbound tap counter — /api/magazine/:id/tap?to=sponsor|share|website|other
app.get('/api/magazine/:id/tap', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const to = String(req.query.to || 'other').slice(0, 32).replace(/[^a-z0-9_-]/gi, '');
    if (!Number.isFinite(id)) return res.status(400).end();
    await query(
      `UPDATE magazine_features
       SET taps = taps + 1,
           taps_breakdown = jsonb_set(
             COALESCE(taps_breakdown, '{}'::jsonb),
             ARRAY[$1::text],
             to_jsonb(COALESCE((taps_breakdown->>$1)::int, 0) + 1),
             true
           )
       WHERE id = $2`,
      [to, id]
    );
    // Redirect to the dest if provided as ?dest=
    const dest = String(req.query.dest || '');
    if (dest && /^https?:\/\//.test(dest)) return res.redirect(302, dest);
    res.json({ ok: true, to });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// JSON Feed 1.1 — full feed of published features (https://jsonfeed.org/)
// Optional: ?cat=<category-tag> filters to a single vertical, ?limit=N (max 500).
app.get('/api/magazine.json', async (req, res) => {
  try {
    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
    const cat = String(req.query.cat || '').trim() || null;
    const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || 200), 10) || 200));
    const where: string[] = [`mf.status = 'published'`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
    params.push(limit);
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              mf.generated_at, mf.published_at,
              b.name AS biz_name, b.address AS biz_address, b.city
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY mf.published_at DESC NULLS LAST, mf.generated_at DESC
       LIMIT $${params.length}`,
      params
    );
    res.json({
      version: 'https://jsonfeed.org/version/1.1',
      title: cat ? `The Corridor · ${cat}` : 'The Corridor',
      home_page_url: `${baseUrl}/issue${cat ? '/' + cat : ''}`,
      feed_url: `${baseUrl}/api/magazine.json${cat ? '?cat=' + cat : ''}`,
      description: cat ? `${cat} on Ventura Boulevard.` : "A magazine of who's here on Ventura Boulevard.",
      authors: [{ name: 'The Corridor desk' }],
      language: 'en',
      items: r.rows.map((f: any) => ({
        id: `${baseUrl}/magazine/${f.id}`,
        url: `${baseUrl}/magazine/${f.id}`,
        title: f.headline || f.biz_name,
        summary: f.subhead || '',
        content_text: f.editorial || '',
        date_published: f.published_at ? new Date(f.published_at).toISOString() : new Date(f.generated_at).toISOString(),
        tags: [f.category_tag].filter(Boolean),
        image: `${baseUrl}/share/${f.id}`,
        _corridor: { biz_name: f.biz_name, biz_address: f.biz_address, city: f.city, pull_quote: f.pull_quote }
      }))
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Magazine CSV export — every published feature for accountant/CRM/spreadsheet hand-off
app.get('/api/magazine.csv', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.headline, mf.subhead, mf.category_tag, mf.status, mf.model, mf.views,
             to_char(mf.generated_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM-DD HH24:MI') AS generated_pt,
             to_char(mf.published_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM-DD HH24:MI') AS published_pt,
             length(mf.editorial) AS editorial_len,
             b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
             COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
      ORDER BY mf.id ASC
    `);
    const headers = ['id','headline','subhead','category_tag','status','model','views','generated_pt','published_pt','editorial_len','biz_name','biz_address','city','zip','paid_ads_count'];
    const rows = r.rows.map((row: any) => headers.map(h => {
      const v = row[h];
      if (v === null || v === undefined) return '';
      const s = String(v);
      return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
    }).join(','));
    const csv = [headers.join(','), ...rows].join('\n');
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename="the-corridor-${new Date().toISOString().slice(0,10)}.csv"`);
    res.send(csv);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/magazine/ad-heatmap', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        mf.category_tag                                               AS vertical,
        count(*)                                                      AS features,
        count(*) FILTER (WHERE COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) > 0) AS sponsor_candidates,
        count(*) FILTER (WHERE (be.ad_signals->>'google_ads')::boolean)                  AS google_ads,
        count(*) FILTER (WHERE (be.ad_signals->>'meta_pixel')::boolean)                  AS meta_pixel,
        count(*) FILTER (WHERE (be.ad_signals->>'tiktok_pixel')::boolean)                AS tiktok_pixel,
        count(*) FILTER (WHERE (be.ad_signals->>'linkedin_insight')::boolean)            AS linkedin_insight,
        count(*) FILTER (WHERE (be.ad_signals->>'pinterest_tag')::boolean)               AS pinterest_tag,
        ROUND(100.0 * count(*) FILTER (WHERE COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) > 0)
              / NULLIF(count(*), 0), 1) AS sponsor_rate_pct
      FROM magazine_features mf
      LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
      WHERE mf.category_tag IS NOT NULL
      GROUP BY mf.category_tag
      ORDER BY sponsor_candidates DESC NULLS LAST, features DESC
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/magazine/scoreboard', async (_req, res) => {
  try {
    const [topViewed, hotVerticals, latest, longest, sponsors, topTapped] = await Promise.all([
      query(`SELECT mf.id, mf.headline, mf.views, b.name AS biz_name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id WHERE mf.views > 0 ORDER BY mf.views DESC LIMIT 10`),
      query(`SELECT category_tag, count(*) AS n FROM magazine_features GROUP BY category_tag ORDER BY n DESC LIMIT 10`),
      query(`SELECT mf.id, mf.headline, mf.generated_at, b.name AS biz_name, mf.category_tag FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id ORDER BY mf.generated_at DESC LIMIT 10`),
      query(`SELECT mf.id, mf.headline, length(mf.editorial) AS len, b.name AS biz_name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id WHERE mf.editorial IS NOT NULL ORDER BY length(mf.editorial) DESC LIMIT 10`),
      query(`SELECT mf.id, mf.headline, b.name AS biz_name, COALESCE((be.ad_signals->>'paid_ads_count')::int,0) AS ads FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id LEFT JOIN business_enrichment be ON be.business_id=mf.business_id WHERE COALESCE((be.ad_signals->>'paid_ads_count')::int,0) > 0 ORDER BY ads DESC LIMIT 10`),
      query(`SELECT mf.id, mf.headline, mf.taps, mf.taps_breakdown, b.name AS biz_name FROM magazine_features mf JOIN businesses b ON b.id=mf.business_id WHERE mf.taps > 0 ORDER BY mf.taps DESC LIMIT 10`)
    ]);
    res.json({
      top_viewed: topViewed.rows,
      hot_verticals: hotVerticals.rows,
      latest: latest.rows,
      longest_editorial: longest.rows,
      sponsor_candidates: sponsors.rows,
      top_tapped: topTapped.rows
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/magazine/stats', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        count(*)                                            AS total,
        count(*) FILTER (WHERE status = 'draft')            AS draft,
        count(*) FILTER (WHERE status = 'reviewed')         AS reviewed,
        count(*) FILTER (WHERE status = 'published')        AS published,
        count(*) FILTER (WHERE status = 'spiked')           AS spiked,
        count(*) FILTER (WHERE generated_at >= NOW() - INTERVAL '24 hours') AS gen_24h,
        max(generated_at)                                   AS last_generated_at
      FROM magazine_features
    `);
    const corr = await query(`SELECT count(*) AS n FROM businesses WHERE on_corridor AND merged_into IS NULL`);
    res.json({ ...r.rows[0], corridor_total: Number(corr.rows[0].n) });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Magazine: AI-generated features for corridor businesses ────────
app.get('/api/magazine', async (req, res) => {
  try {
    const status = String(req.query.status || 'all'); // all | draft | reviewed | published
    const cat    = String(req.query.cat || 'all');
    const where: string[] = [];
    const params: any[] = [];
    if (status !== 'all') { params.push(status); where.push(`mf.status = $${params.length}`); }
    if (cat !== 'all')    { params.push(cat);    where.push(`mf.category_tag = $${params.length}`); }
    const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
    params.push(limit);
    const r = await query(
      `SELECT mf.id, mf.business_id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote,
              mf.category_tag, mf.photo_url, mf.status, mf.model, mf.generated_at,
              mf.reviewed_at, mf.published_at, mf.views, mf.taps, mf.taps_breakdown, mf.notes,
              b.name, b.address, b.city, b.zip,
              b.raw->>'primary_naics_description' AS naics,
              be.ad_signals,
              COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count
       FROM magazine_features mf
       JOIN businesses b ON b.id = mf.business_id
       LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
       ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
       ORDER BY mf.generated_at DESC
       LIMIT $${params.length}`,
      params
    );
    const counts = await query(`SELECT status, count(*) AS n FROM magazine_features GROUP BY status`);
    res.json({ count: r.rowCount, rows: r.rows, by_status: counts.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Regenerate a single feature via Mac1 Ollama directly (no shell-out).
// Same prompt logic as src/jobs/generate_features.ts — kept in sync.
app.post('/api/magazine/:id/regen', express.json(), async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
    const r = await query(
      `SELECT mf.id, mf.business_id,
              b.name, b.address, b.city, b.zip, b.category,
              b.raw->>'primary_naics_description' AS naics,
              p.pitch_type,
              TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
       FROM magazine_features mf
       JOIN businesses b ON b.id = mf.business_id
       LEFT JOIN pitches p ON p.business_id = mf.business_id
       WHERE mf.id = $1`,
      [id]
    );
    if (r.rowCount === 0) return res.status(404).json({ error: 'feature not found' });
    const biz = r.rows[0];

    const SYSTEM = `You are a corridor editor writing flattering, magazine-style ~100-word features for businesses on Ventura Boulevard in Sherman Oaks / Encino / Tarzana. Your tone is warm but never gushing — think Conde Nast Traveler shopping guide.

HEADLINE STYLE — pick ONE of these abstract voice patterns each time, but write a phrase UNIQUE to THIS business. Do NOT reuse any example below verbatim.
  1. Verb-led participle: an "-ing" verb + sensory or material noun specific to this business's actual trade
  2. Specific-noun-first: a concrete piece of the storefront ("counter", "doorway", "shelf", "stool", "case") + the business's own name or address
  3. Two-noun sensory pair: two short noun phrases joined by "and" or comma, each from this trade's vocabulary (a baker has "crumb" and "yeast"; a cobbler has "leather" and "wax")
  4. Contrast: an unexpected pairing of two things that ARE actually present at this address (e.g. an old building + new service inside it)
  5. Time + place: a specific moment of day or week + the business's address or block
  6. Fragment / question: a partial sentence or rhetorical fragment about something visible at the storefront

NEVER reuse these stale phrasings: "X in Y's Heart", "X Meets Y", "Where X Meets Y", "X Rooted in Y", "Precision Care", "Legal Precision", "Legal Expertise", "Steady Counsel", "Timeless Elegance", "Vibrant Hub", "Quiet Office Loud Reputation", "Espresso Steam".

The headline must reflect THIS specific business — its trade, its actual location, its real building. Do not reach for stock images.

Output a JSON object with EXACTLY these fields:
{
  "headline":   "6-10 word title in one of the 6 styles above",
  "subhead":    "12-18 word subtitle",
  "editorial":  "80-130 word feature paragraph",
  "pull_quote": "8-15 word callout",
  "category_tag": "one of: restaurant, professional, beauty, shop, fitness, medical, real-estate, hospitality, salon, automotive"
}

Rules:
- Never invent specific prices, opening dates, owner names, or claims you can't verify.
- Lean on the address as a sense of place ("just east of Sepulveda" / "in the white-stone tower at 15821 Ventura").
- Avoid superlatives ("the BEST"). Prefer concrete sensory details.
- Output ONLY the JSON. No preamble, no markdown fences.`;
    const userPrompt = `Business: ${biz.name}
Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
Building context: ${biz.bldg_address ? `Inside the multi-tenant building at ${biz.bldg_address}` : 'Storefront on Ventura'}

Write the feature.`;

    const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434'; // old 100.94.103.98 default = dead mac2-era tailnet IP
    const MODEL  = process.env.OLLAMA_MODEL || 'qwen3:14b';
    const t0 = Date.now();
    const ollResp = await fetch(`${OLLAMA}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: MODEL,
        messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: userPrompt }],
        stream: false,
        options: { temperature: 0.7 },
        format: 'json'
      })
    });
    if (!ollResp.ok) return res.status(502).json({ error: `ollama ${ollResp.status}: ${await ollResp.text()}` });
    const j = await ollResp.json();
    const raw = j.message?.content || '';
    let feat: any;
    try { feat = JSON.parse(raw); }
    catch { return res.status(502).json({ error: 'bad JSON from model', sample: raw.slice(0, 200) }); }

    // Preserve curation state — if already published or reviewed, regen keeps that status.
    // Only fresh drafts revert to 'draft'. This protects the front-page from auto-dedupe demotion.
    await query(
      `UPDATE magazine_features SET
         headline=$1, subhead=$2, editorial=$3, pull_quote=$4, category_tag=$5,
         model=$6, generated_at=NOW()
       WHERE id=$7`,
      [feat.headline, feat.subhead, feat.editorial, feat.pull_quote, feat.category_tag, MODEL, id]
    );
    res.json({ ok: true, ms: Date.now() - t0, headline: feat.headline });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// /issue/feed.xml or /issue/:cat/feed.xml — RSS feed (must be defined BEFORE /issue/:cat
// or Express matches "feed.xml" as a category)
app.get(['/issue/feed.xml', '/issue/:cat/feed.xml'], async (req, res) => {
  try {
    const cat = (req.params as any).cat || null;
    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
    const where: string[] = [`status = 'published'`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`category_tag = $${params.length}`); }
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.published_at, mf.category_tag,
              b.name AS biz_name
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY published_at DESC LIMIT 50`,
      params
    );
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const items = r.rows.map((f: any) => `
    <item>
      <title>${esc(f.headline || f.biz_name)}</title>
      <link>${baseUrl}/magazine/${f.id}</link>
      <guid isPermaLink="true">${baseUrl}/magazine/${f.id}</guid>
      <pubDate>${new Date(f.published_at || Date.now()).toUTCString()}</pubDate>
      <category>${esc(f.category_tag || 'feature')}</category>
      <description>${esc(f.subhead || '')} — ${esc(f.biz_name)}</description>
      <content:encoded><![CDATA[${esc(f.editorial || '')}]]></content:encoded>
    </item>`).join('');
    const title = cat ? `The Corridor — ${cat}` : 'The Corridor';
    res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
    res.send(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>${esc(title)}</title>
    <link>${baseUrl}${cat ? '/issue/' + esc(cat) : '/issue'}</link>
    <description>A magazine of who's here on Ventura Boulevard.${cat ? ' Vertical: ' + esc(cat) + '.' : ''}</description>
    <language>en-us</language>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
  </channel>
</rss>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// /issue/archive — index of every month that has a published feature
app.get('/issue/archive', async (_req, res) => {
  const r = await query(`
    SELECT to_char(mf.published_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM') AS month,
           count(*) AS n,
           min(mf.published_at) AS first_pub,
           max(mf.published_at) AS last_pub,
           i.cover_feature_id, i.cover_image_path,
           cmf.headline AS cover_headline, b.name AS cover_biz
    FROM magazine_features mf
    LEFT JOIN magazine_issues i ON i.issue_month = to_char(mf.published_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM')
    LEFT JOIN magazine_features cmf ON cmf.id = i.cover_feature_id
    LEFT JOIN businesses b ON b.id = cmf.business_id
    WHERE mf.status = 'published' AND mf.published_at IS NOT NULL
    GROUP BY 1, i.cover_feature_id, i.cover_image_path, cmf.headline, b.name
    ORDER BY 1 DESC
  `);
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  const monthsHtml = r.rows.length === 0
    ? `<div style="text-align:center;padding:80px 24px;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:22px">No issues published yet.</div>`
    : r.rows.map((m: any) => {
        const [yy, mm] = String(m.month).split('-').map(Number);
        const mLabel = new Date(yy, mm - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
        const dateRange = `${new Date(m.first_pub).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${new Date(m.last_pub).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
        const coverImg = m.cover_image_path ? `/covers/${esc(m.cover_image_path)}` : null;
        return `<a href="/issue/${esc(m.month)}" class="month-card${coverImg ? ' has-img' : ''}">
          ${coverImg ? `<div class="cover-img" style="background-image:url('${coverImg}')"></div><div class="img-overlay"></div>` : ''}
          <div class="month-card-inner">
            <div class="kicker">${esc(m.month)}</div>
            <div class="month">${esc(mLabel)}</div>
            ${m.cover_headline ? `<div class="cover-line">★ ${esc(m.cover_headline)}<small>${esc(m.cover_biz || '')}</small></div>` : ''}
            <div class="stats"><b>${m.n}</b> feature${Number(m.n) === 1 ? '' : 's'} · ${esc(dateRange)}</div>
          </div>
        </a>`;
      }).join('');

  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Archive · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.5}
header{text-align:center;padding:64px 24px 36px;border-bottom:4px double var(--ink)}
header .kicker{font-size:10px;letter-spacing:.45em;text-transform:uppercase;color:var(--metal);font-weight:500}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:14px 0 0;letter-spacing:-0.02em}
header h1 em{font-style:normal;color:var(--metal)}
header .deck{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink-mute);font-size:18px;margin-top:12px}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px}.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
main{max-width:1100px;margin:0 auto;padding:48px 24px 80px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px}
.month-card{position:relative;display:block;background:rgba(184,153,104,0.04);border:1px solid var(--rule);text-decoration:none;color:inherit;padding:0;overflow:hidden;min-height:220px;transition:transform 0.15s ease,border-color 0.15s ease}
.month-card:hover{border-color:var(--metal);transform:translateY(-2px)}
.month-card-inner{padding:24px 22px}
.month-card.has-img{color:var(--paper)}
.month-card.has-img .month-card-inner{position:relative;z-index:2}
.month-card.has-img .kicker{color:var(--metal-glow)}
.month-card.has-img .cover-line{color:rgba(250,246,238,0.95)}
.month-card.has-img .stats{color:rgba(250,246,238,0.7)}
.month-card .cover-img{position:absolute;inset:0;background-size:cover;background-position:center;filter:saturate(0.85)}
.month-card .img-overlay{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0.2) 0%,rgba(0,0,0,0.85) 100%);z-index:1}
.kicker{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500;margin-bottom:6px}
.month{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:32px;line-height:1.05;margin-bottom:12px}
.cover-line{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:16px;line-height:1.4;color:var(--ink);margin:14px 0 6px}
.cover-line small{display:block;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px;font-style:normal}
.stats{font-size:11px;color:var(--ink-mute);margin-top:14px;padding-top:14px;border-top:1px dotted var(--rule)}
.stats b{color:var(--metal);font-weight:500}
</style></head>
<body>
<div class="toolbar">
  <a href="/issue">Current issue</a>
  <a href="/about.html">About</a>
  <a href="/find.html">Find</a>
</div>
<header>
  <div class="kicker">The Corridor · Archive</div>
  <h1>Back <em>issues</em>.</h1>
  <div class="deck">Every month, every feature.</div>
</header>
<main>${monthsHtml}</main>
</body></html>`);
});

// /issue — the curated public-facing issue (status='published' only)
// Mirrors /magazine.html aesthetic but stripped of admin controls + prints clean.
app.get(['/issue', '/issue/:cat'], async (req, res) => {
  let cat = (req.params as any).cat || null;
  // /issue/YYYY-MM is a monthly archive — features published that month.
  // /issue/<category-tag> is a vertical view of all-time published features.
  let monthArchive: string | null = null;
  if (cat && /^\d{4}-\d{2}$/.test(cat)) {
    monthArchive = cat;
    cat = null;
  }
  const where: string[] = [`mf.status = 'published'`];
  const params: any[] = [];
  if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
  if (monthArchive) {
    params.push(monthArchive);
    where.push(`to_char(mf.published_at AT TIME ZONE 'America/Los_Angeles', 'YYYY-MM') = $${params.length}`);
  }
  const r = await query(
    `SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
            COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS paid_ads_count
     FROM magazine_features mf
     JOIN businesses b ON b.id = mf.business_id
     LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
     WHERE ${where.join(' AND ')}
     ORDER BY mf.published_at DESC NULLS LAST, mf.generated_at DESC`,
    params
  );
  // Category counts for nav-strip on the public /issue page
  const catRes = await query(
    `SELECT category_tag, count(*) AS n FROM magazine_features
     WHERE status = 'published' GROUP BY category_tag ORDER BY n DESC`
  );
  // Cover for current month — only render on the all-features (no cat / no monthArchive) view
  let cover: any = null;
  if (!cat && !monthArchive) {
    const month = new Date().toISOString().slice(0, 7);
    const cv = await query(`
      SELECT i.cover_caption, i.cover_kicker, i.cover_image_path,
             mf.id, mf.headline, mf.subhead, mf.pull_quote, mf.category_tag,
             b.name AS biz_name
      FROM magazine_issues i
      JOIN magazine_features mf ON mf.id = i.cover_feature_id
      JOIN businesses b ON b.id = mf.business_id
      WHERE i.issue_month = $1 AND mf.status = 'published'
    `, [month]);
    if (cv.rowCount) cover = cv.rows[0];
  }
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  const rows = r.rows;
  const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
  const issueTitle = monthArchive ? `Archive · ${monthArchive}` : (cat ? `${cat} on The Corridor` : `The Corridor · Volume I`);
  const issueDesc = cat
    ? `${rows.length} ${cat} feature${rows.length === 1 ? '' : 's'} on Ventura Boulevard.`
    : `${rows.length} stories from Ventura Boulevard's restaurants, professionals, and ateliers.`;
  const issueUrl = `${baseUrl}/issue${cat ? '/' + cat : (monthArchive ? '/' + monthArchive : '')}`;
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>The Corridor · ${monthArchive ? esc(monthArchive) + ' archive' : 'Issue'}${cat ? ' · ' + esc(cat) : ''}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="${esc(issueDesc)}">
<meta property="og:type" content="website">
<meta property="og:title" content="${esc(issueTitle)}">
<meta property="og:description" content="${esc(issueDesc)}">
<meta property="og:url" content="${issueUrl}">
<meta property="og:site_name" content="The Corridor">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(issueTitle)}">
<meta name="twitter:description" content="${esc(issueDesc)}">
${rows[0] ? `<meta property="og:image" content="${baseUrl}/share/${rows[0].id}"><meta name="twitter:image" content="${baseUrl}/share/${rows[0].id}">` : ''}
${cat ? `<link rel="alternate" type="application/rss+xml" title="${esc(issueTitle)} feed" href="${baseUrl}/issue/${cat}/feed.xml">` : `<link rel="alternate" type="application/rss+xml" title="The Corridor feed" href="${baseUrl}/issue/feed.xml">`}
<link rel="alternate" type="application/json" title="The Corridor JSON Feed" href="${baseUrl}/api/magazine.json">
<script type="application/ld+json">${JSON.stringify({
  '@context': 'https://schema.org',
  '@type': 'CollectionPage',
  name: issueTitle,
  description: issueDesc,
  url: issueUrl,
  publisher: { '@type': 'Organization', name: 'The Corridor' },
  numberOfItems: rows.length,
  hasPart: rows.slice(0, 30).map((f: any) => ({
    '@type': 'NewsArticle',
    headline: f.headline || f.biz_name,
    url: `${baseUrl}/magazine/${f.id}`,
    articleSection: f.category_tag,
  }))
})}</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',system-ui,sans-serif;font-weight:300}
header{text-align:center;padding:64px 24px 36px;border-bottom:4px double var(--ink)}
header .kicker{font-size:10px;letter-spacing:.45em;text-transform:uppercase;color:var(--metal);font-weight:500;margin-bottom:16px}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:0;letter-spacing:-0.02em}
header h1 em{font-style:normal;color:var(--metal)}
header .deck{font-family:'Cormorant Garamond',serif;font-weight:300;font-style:italic;font-size:18px;color:var(--ink-mute);margin-top:14px;max-width:560px;margin-left:auto;margin-right:auto;line-height:1.5}
header .stat{margin-top:18px;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);font-family:'Inter',sans-serif;font-weight:400}
main{max-width:1240px;margin:0 auto;padding:48px 24px 100px;column-count:3;column-gap:48px}
@media(max-width:1100px){main{column-count:2}}
@media(max-width:680px){main{column-count:1}}
article{break-inside:avoid;margin-bottom:48px;padding-bottom:32px;border-bottom:1px solid var(--rule)}
article:last-child{border-bottom:none}
article .cat{display:inline-block;background:var(--ink);color:var(--paper);padding:3px 10px;font-size:8px;letter-spacing:.32em;text-transform:uppercase;font-weight:500;margin-bottom:14px}
article .ads{display:inline-block;margin-left:6px;background:var(--accent);color:var(--paper);padding:3px 8px;font-size:8px;letter-spacing:.3em;text-transform:uppercase;font-weight:500}
article h2{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:30px;line-height:1.05;margin:0 0 8px;letter-spacing:-0.01em}
article h2 a{color:var(--ink);text-decoration:none}
article .subhead{font-family:'Cormorant Garamond',serif;font-weight:300;font-style:italic;font-size:15px;color:var(--ink-mute);margin:0 0 14px;line-height:1.4}
article .lede{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:15px;line-height:1.6;color:var(--ink);margin:0 0 14px}
article .lede::first-letter{font-family:'Cormorant Garamond',serif;font-weight:600;font-size:48px;line-height:.85;float:left;margin:4px 8px 0 0;color:var(--accent)}
article .pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:17px;line-height:1.35;color:var(--accent);border-left:2px solid var(--metal);padding:6px 14px;margin:12px 0}
article .biz{margin-top:14px;font-family:'Inter',sans-serif;font-weight:500;font-size:11px;letter-spacing:.02em;color:var(--ink)}
article .biz small{display:block;font-family:monospace;font-size:9px;color:var(--ink-mute);letter-spacing:.08em;margin-top:2px;font-weight:400}
.empty{text-align:center;padding:120px 24px;color:var(--ink-mute);font-family:'Cormorant Garamond',serif;font-style:italic;font-size:24px}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
@media print{.toolbar{display:none}main{column-count:2;padding:24px}article{break-inside:avoid}header{padding:24px 0}header h1{font-size:56px}}
</style></head><body>
<div class="toolbar">
  <a href="/magazine.html">⚙ Admin</a>
  <a href="/issue.pdf${cat ? '?cat=' + encodeURIComponent(cat) : ''}" download>⬇ PDF</a>
  <a href="/issue.epub${cat ? '?cat=' + encodeURIComponent(cat) : ''}" download>📖 EPUB</a>
  <a href="/issue.txt${cat ? '?cat=' + encodeURIComponent(cat) : ''}" target="_blank">📄 .txt</a>
  <a href="/issue/archive">🗂 Archive</a>
  <a href="/quotes">" Quotes</a>
  <a href="javascript:window.print()">🖨 Print</a>
</div>
<header>
  <div class="kicker">${monthArchive ? `Archive · ${esc(monthArchive)}` : 'Volume I · Spring Issue · 2026'}${cat ? ' · ' + esc(cat).toUpperCase() : ''}</div>
  <h1>The <em>Corridor</em></h1>
  <div class="deck">A magazine of who's here. The corridor's restaurants, professionals, ateliers, and ateliers-disguised-as-storefronts — written warm.</div>
  <div class="stat">${rows.length} feature${rows.length===1?'':'s'} · ${new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'})}</div>
</header>
${cover ? `<section style="padding:${cover.cover_image_path ? '0' : '48px 24px 56px'};border-bottom:1px solid var(--rule);background:linear-gradient(180deg,rgba(184,153,104,0.05) 0%,transparent 100%);position:relative">
  ${cover.cover_image_path ? `<a href="/magazine/${cover.id}" style="display:block;line-height:0;position:relative">
    <img src="/covers/${esc(cover.cover_image_path)}" alt="${esc(cover.headline)}" style="width:100%;max-height:560px;object-fit:cover;display:block;filter:saturate(0.92)">
    <div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 35%,rgba(0,0,0,0.65) 100%)"></div>
    <div style="position:absolute;left:0;right:0;bottom:0;padding:36px 32px;color:#faf6ee">
      <div style="font-size:10px;letter-spacing:.5em;text-transform:uppercase;color:#d4b683;font-weight:500;margin-bottom:14px">${esc(cover.cover_kicker || 'On the cover this month')}</div>
      <h2 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:60px;line-height:1.02;margin:0 0 12px;color:#faf6ee;letter-spacing:-0.01em">${esc(cover.headline)}</h2>
      ${cover.cover_caption ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.45;color:rgba(250,246,238,0.88);margin:0 0 12px;max-width:680px;font-weight:300">${esc(cover.cover_caption)}</p>` : (cover.subhead ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.45;color:rgba(250,246,238,0.88);margin:0 0 12px;max-width:680px;font-weight:300">${esc(cover.subhead)}</p>` : '')}
      <div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:rgba(250,246,238,0.65)">${esc(cover.biz_name)} · ${esc(cover.category_tag || '')}</div>
    </div>
  </a>` : `<div style="max-width:780px;margin:0 auto;text-align:center">
    <div style="font-size:9px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal);font-weight:500;margin-bottom:18px">${esc(cover.cover_kicker || 'On the cover this month')}</div>
    <h2 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;line-height:1.05;margin:0 0 18px;color:var(--ink);letter-spacing:-0.01em"><a href="/magazine/${cover.id}" style="color:inherit;text-decoration:none;border-bottom:1px solid transparent;padding-bottom:2px" onmouseover="this.style.borderColor='var(--metal)'" onmouseout="this.style.borderColor='transparent'">${esc(cover.headline)}</a></h2>
    ${cover.cover_caption ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.5;color:var(--ink-mute);margin:0 0 16px;font-weight:300">${esc(cover.cover_caption)}</p>` : (cover.subhead ? `<p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:20px;line-height:1.5;color:var(--ink-mute);margin:0 0 16px;font-weight:300">${esc(cover.subhead)}</p>` : '')}
    <div style="font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:12px">${esc(cover.biz_name)} · ${esc(cover.category_tag || '')}</div>
  </div>`}
</section>` : ''}
${catRes.rows.length > 0 ? `<nav style="text-align:center;padding:14px 24px;border-bottom:1px solid var(--rule);font-family:'Inter',sans-serif">
  <a href="/issue" style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:${cat ? 'var(--ink-mute)' : 'var(--ink)'};text-decoration:none;margin:0 12px;font-weight:${cat ? 400 : 600}">All</a>
  ${catRes.rows.map((c: any) => `<a href="/issue/${esc(c.category_tag)}" style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:${cat === c.category_tag ? 'var(--ink)' : 'var(--ink-mute)'};text-decoration:none;margin:0 12px;font-weight:${cat === c.category_tag ? 600 : 400}">${esc((c.category_tag || 'misc').replace(/-/g, ' '))} <span style="color:var(--metal);margin-left:2px">${c.n}</span></a>`).join('')}
</nav>` : ''}
<main>
${rows.length === 0 ? `<div class="empty">No features published yet.<br><br><span style="font-size:14px;font-style:normal;color:var(--metal);font-family:Inter">Mark drafts as <strong>Reviewed</strong> then <strong>Publish</strong> on the admin page.</span></div>` :
rows.map((f: any) => `
  <article>
    <div>
      <span class="cat">${esc(f.category_tag || 'feature')}</span>
      ${f.paid_ads_count > 0 ? `<span class="ads">$ sponsor candidate</span>` : ''}
    </div>
    <h2><a href="/magazine/${f.id}">${esc(f.headline || f.biz_name)}</a></h2>
    <div class="subhead">${esc(f.subhead || '')}</div>
    <p class="lede">${esc(f.editorial || '')}</p>
    ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
    <div class="biz">${esc(f.biz_name)}<small>${esc(f.biz_address || '')}${f.city ? ' · ' + esc(f.city) : ''}</small></div>
  </article>
`).join('')}
</main>
</body></html>`);
});

// /sitemap.xml — every published feature + every per-vertical issue page
app.get('/sitemap.xml', async (_req, res) => {
  try {
    const _esc0 = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const baseUrl = _esc0(process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780');
    const r = await query(`
      SELECT id, COALESCE(published_at, generated_at) AS ts
      FROM magazine_features
      WHERE status = 'published'
      ORDER BY published_at DESC
    `);
    const cats = await query(`
      SELECT DISTINCT category_tag FROM magazine_features WHERE status='published' AND category_tag IS NOT NULL
    `);
    const fmt = (d: any) => new Date(d).toISOString();
    const urls: string[] = [
      `<url><loc>${baseUrl}/issue</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`,
      `<url><loc>${baseUrl}/scoreboard.html</loc><changefreq>daily</changefreq><priority>0.6</priority></url>`,
      `<url><loc>${baseUrl}/words</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>`,
      `<url><loc>${baseUrl}/rate-card.html</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`,
      `<url><loc>${baseUrl}/news.html</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>`,
      `<url><loc>${baseUrl}/this-week.html</loc><changefreq>daily</changefreq><priority>0.8</priority></url>`,
      `<url><loc>${baseUrl}/coverage.html</loc><changefreq>daily</changefreq><priority>0.5</priority></url>`,
    ];
    // One <url> per business with at least one scraped article — these
    // /business/:id/news pages are real content surfaces, not just stubs,
    // and are worth crawling. Cap at 1000 so the sitemap stays sane.
    const newsBiz = await query(`
      SELECT DISTINCT business_id, MAX(fetched_at) AS last_fetched
        FROM news_items GROUP BY business_id
       ORDER BY MAX(fetched_at) DESC LIMIT 1000
    `);
    for (const nb of newsBiz.rows) {
      urls.push(`<url><loc>${baseUrl}/business/${nb.business_id}/news</loc><lastmod>${fmt(nb.last_fetched)}</lastmod><changefreq>weekly</changefreq><priority>0.5</priority></url>`);
    }
    for (const c of cats.rows) {
      urls.push(`<url><loc>${baseUrl}/issue/${c.category_tag}</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`);
    }
    for (const f of r.rows) {
      urls.push(`<url><loc>${baseUrl}/magazine/${f.id}</loc><lastmod>${fmt(f.ts)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`);
    }
    res.setHeader('Content-Type', 'application/xml; charset=utf-8');
    res.send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// (RSS route moved earlier — see /issue/feed.xml above)
app.get('/__rss-removed', (_req, res) => {
  res.status(410).send('moved');
});

app.get('/robots.txt', (_req, res) => {
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  // Loopback-only: discourage crawl entirely until Steve goes public.
  res.send(`User-agent: *
Disallow: /

# The Corridor magazine — local development build.
# When this site goes public, swap Disallow back to Allow and uncomment Sitemap.
# Sitemap: /sitemap.xml
`);
});

// /quotes — wall of every editorial pull quote across every published feature.
// Reads as a typographic gallery; click any quote to jump to its feature.
app.get('/quotes', async (_req, res) => {
  try {
    const r = await query(`
      SELECT mf.id, mf.pull_quote, mf.headline, mf.category_tag,
             b.name AS biz_name, b.city
      FROM magazine_features mf
      JOIN businesses b ON b.id = mf.business_id
      WHERE mf.status = 'published'
        AND mf.pull_quote IS NOT NULL
        AND length(mf.pull_quote) > 16
      ORDER BY length(mf.pull_quote) ASC
    `);
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const rows = r.rows;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Quotes · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="${rows.length} editorial pull quotes from features on Ventura Boulevard.">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.5}
header{text-align:center;padding:64px 24px 36px;border-bottom:4px double var(--ink)}
header .kicker{font-size:10px;letter-spacing:.45em;text-transform:uppercase;color:var(--metal);font-weight:500}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:14px 0 0;letter-spacing:-0.02em}
header h1 em{font-style:normal;color:var(--metal)}
header .deck{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink-mute);font-size:18px;margin-top:12px}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
main{max-width:1200px;margin:0 auto;padding:48px 32px 80px;column-count:3;column-gap:36px;column-rule:1px dotted var(--rule)}
@media (max-width: 900px){main{column-count:2}}
@media (max-width: 600px){main{column-count:1}}
figure{break-inside:avoid;margin:0 0 36px;padding:0 4px;page-break-inside:avoid}
figure blockquote{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;line-height:1.35;color:var(--ink);margin:0;text-indent:-0.45em;padding-left:0.45em}
figure blockquote::before{content:'"';color:var(--accent);font-weight:600}
figure blockquote::after{content:'"';color:var(--accent);font-weight:600}
figure.s blockquote{font-size:18px}
figure.m blockquote{font-size:22px}
figure.l blockquote{font-size:26px}
figure.xl blockquote{font-size:30px}
figure figcaption{margin-top:10px;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute)}
figure figcaption a{color:var(--metal);text-decoration:none}
figure figcaption a:hover{color:var(--accent)}
.empty{text-align:center;padding:80px;column-span:all;font-style:italic;font-family:'Cormorant Garamond',serif;font-size:22px;color:var(--ink-mute)}
@media print{.toolbar{display:none}main{column-count:2}}
</style></head><body>
<div class="toolbar">
  <a href="/issue">Issue</a>
  <a href="/words">Words</a>
  <a href="/about.html">About</a>
</div>
<header>
  <div class="kicker">The Corridor · in their own pages</div>
  <h1>Pull <em>quotes</em>.</h1>
  <div class="deck">${rows.length} editorial flourish${rows.length === 1 ? '' : 'es'} drawn from every published feature.</div>
</header>
<main>
${rows.length === 0 ? `<div class="empty">No quotes yet. Check back after the next batch.</div>` :
  rows.map((q, i) => {
    const len = (q.pull_quote || '').length;
    const cls = len < 60 ? 'l' : len < 100 ? 'm' : len < 160 ? 's' : 's';
    // Hero-rotation: every 7th quote gets xl treatment
    const finalCls = i % 7 === 3 ? 'xl' : cls;
    return `<figure class="${finalCls}">
      <blockquote>${esc(q.pull_quote)}</blockquote>
      <figcaption><a href="/magazine/${q.id}">${esc(q.headline)}</a> · ${esc(q.biz_name)}${q.city ? ' · ' + esc(q.city) : ''}</figcaption>
    </figure>`;
  }).join('')}
</main>
</body></html>`);
  } catch (e: any) {
    res.status(500).send('quotes failed: ' + e.message);
  }
});

// /words — what words does The Corridor desk reach for most? frequency analysis
// of all editorial bodies, minus a stop-word list. Renders a typographic word
// cloud sized by frequency.
app.get('/words', async (_req, res) => {
  try {
    const r = await query(`SELECT editorial FROM magazine_features WHERE editorial IS NOT NULL`);
    const STOP = new Set(`a about above after again all also am an and any are as at be been before being below between both but by came can come could did do does doing down during each each few first for from further get gets going got had has have having he he'd he'll he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is it it's its itself just like make many may me more most much my myself nor no not now of off on once one only or other ought our ours ourselves out over own same she she'd she'll she's should so some such than that that's the their theirs them themselves then there there's these they they'd they'll they're they've this those though through to too under until up upon us very was we we'd we'll we're we've well were what what's when when's where where's which while who who's whom why why's will with won't would you you'd you'll you're you've your yours yourself yourselves new just out way back over still really only also even much because while where here there s t d ll ve re m ventura blvd boulevard sherman oaks encino tarzana studio city woodland hills suite multi tenant building corridor`.split(/\s+/));
    const counts: Record<string, number> = {};
    for (const row of r.rows) {
      const text = (row.editorial || '').toLowerCase();
      const tokens = text.match(/[a-z][a-z'-]{3,}/g) || [];
      for (const t of tokens) {
        if (STOP.has(t)) continue;
        counts[t] = (counts[t] || 0) + 1;
      }
    }
    const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 100);
    if (top.length === 0) {
      res.send('<h1>No editorial yet.</h1>');
      return;
    }
    const max = top[0][1];
    const min = top[top.length - 1][1];
    const fontFor = (n: number) => 13 + Math.round(((n - min) / Math.max(max - min, 1)) * 56);
    const opacityFor = (n: number) => 0.45 + ((n - min) / Math.max(max - min, 1)) * 0.55;
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>Words on the corridor — The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@200;300;400;500&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.55}
header{text-align:center;padding:48px 24px 28px;border-bottom:4px double var(--ink)}
header .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink)}
header h1 em{color:var(--metal);font-style:normal}
header .deck{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:16px;color:var(--ink-mute);margin-top:12px;max-width:560px;margin-left:auto;margin-right:auto}
.cloud{max-width:980px;margin:0 auto;padding:60px 32px 80px;text-align:center;line-height:1.4}
.cloud span{display:inline-block;margin:6px 14px;font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink);transition:color 200ms}
.cloud span:hover{color:var(--accent);font-weight:500}
.cloud span.tier-1{color:var(--accent);font-weight:600}
.cloud span.tier-2{color:var(--metal);font-weight:500}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
</style></head><body>
<div class="toolbar">
  <a href="/issue">Issue</a>
  <a href="/scoreboard.html">Scoreboard</a>
</div>
<header>
  <div class="kicker">The Corridor · word study</div>
  <h1>Words on the <em>corridor</em></h1>
  <div class="deck">${r.rowCount} feature${r.rowCount === 1 ? '' : 's'} written so far · the desk's vocabulary, sized by how often it shows up.</div>
</header>
<div class="cloud">
${top.map(([w, n], i) => {
  const size = fontFor(n);
  const op = opacityFor(n);
  const tier = i < 5 ? 'tier-1' : i < 20 ? 'tier-2' : '';
  return `<span class="${tier}" style="font-size:${size}px;opacity:${op.toFixed(2)}" title="used ${n}×">${esc(w)}</span>`;
}).join(' ')}
</div>
</body></html>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// PDF export — render /issue (or /issue/:cat) via Playwright headless and stream to disk
app.get('/issue.pdf', async (req, res) => {
  try {
    const { chromium } = await import('playwright');
    const cat = String(req.query.cat || '');
    const url = cat ? `http://127.0.0.1:${PORT}/issue/${encodeURIComponent(cat)}` : `http://127.0.0.1:${PORT}/issue`;
    const browser = await chromium.launch();
    const ctx = await browser.newContext({
      httpCredentials: { username: ADMIN_USER, password: ADMIN_PASS }
    });
    const page = await ctx.newPage();
    await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
    const pdf = await page.pdf({ format: 'Letter', printBackground: true, margin: { top: '0.5in', bottom: '0.5in', left: '0.4in', right: '0.4in' } });
    await browser.close();
    const stamp = new Date().toISOString().slice(0, 10);
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', `attachment; filename="the-corridor-${cat || 'issue'}-${stamp}.pdf"`);
    res.send(pdf);
  } catch (e: any) {
    res.status(500).send('PDF render failed: ' + e.message);
  }
});

// /issue.txt — plain-text dump of the entire issue (or by ?cat=).
// Lossless content; readable in any terminal, screen reader, or AI ingest pipeline.
app.get('/issue.txt', async (req, res) => {
  try {
    const cat = String(req.query.cat || '').trim() || null;
    const where: string[] = [`mf.status = 'published'`, `length(mf.editorial) > 100`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              mf.published_at, b.name AS biz_name, b.address AS biz_address, b.city
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY mf.published_at DESC NULLS LAST, mf.id DESC`,
      params
    );
    const stamp = new Date().toISOString().slice(0, 10);
    const lines: string[] = [];
    lines.push('THE CORRIDOR');
    lines.push(cat ? `· ${cat.toUpperCase()} ·` : '· VOLUME I ·');
    lines.push(stamp);
    lines.push('');
    lines.push('A magazine of every business on Ventura Boulevard.');
    lines.push('');
    lines.push('=' .repeat(72));
    for (const f of r.rows) {
      lines.push('');
      lines.push((f.headline || f.biz_name || '').toUpperCase());
      if (f.subhead) lines.push(f.subhead);
      lines.push('');
      lines.push(f.editorial || '');
      if (f.pull_quote) {
        lines.push('');
        lines.push(`    "${f.pull_quote}"`);
      }
      lines.push('');
      lines.push(`— ${f.biz_name}${f.biz_address ? ' · ' + f.biz_address : ''}${f.city ? ' · ' + f.city : ''}`);
      lines.push(`— /magazine/${f.id} · ${f.category_tag || 'feature'}`);
      lines.push('');
      lines.push('-' .repeat(48));
    }
    lines.push('');
    lines.push(`${r.rowCount} features · The Corridor · the-corridor`);
    res.type('text/plain').send(lines.join('\n'));
  } catch (e: any) {
    res.status(500).type('text/plain').send('error: ' + e.message);
  }
});

// /issue.epub — EPUB 2 export of all published features (or by category).
// Pure zip; no Playwright. Readable in Apple Books, Calibre, Kindle (after conversion).
app.get('/issue.epub', async (req, res) => {
  try {
    const archiverMod: any = await import('archiver');
    const archiver = archiverMod.default || archiverMod;
    const cat = String(req.query.cat || '').trim() || null;
    const where: string[] = [`mf.status = 'published'`, `length(mf.editorial) > 200`];
    const params: any[] = [];
    if (cat) { params.push(cat); where.push(`mf.category_tag = $${params.length}`); }
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              mf.published_at, b.name AS biz_name, b.address AS biz_address, b.city
       FROM magazine_features mf
       JOIN businesses b ON b.id = mf.business_id
       WHERE ${where.join(' AND ')}
       ORDER BY mf.published_at DESC NULLS LAST, mf.id DESC`,
      params
    );
    const features = r.rows;
    if (features.length === 0) return res.status(404).send('No features to package');

    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const stamp = new Date().toISOString().slice(0, 10);
    const uid = `urn:uuid:corridor-${stamp}-${cat || 'all'}`;
    const titleSuffix = cat ? ` · ${cat}` : '';
    const title = `The Corridor${titleSuffix} · ${stamp}`;

    res.setHeader('Content-Type', 'application/epub+zip');
    res.setHeader('Content-Disposition', `attachment; filename="the-corridor-${cat || 'issue'}-${stamp}.epub"`);
    const zip = archiver('zip', { store: false });
    zip.on('error', (err: any) => res.status(500).send('EPUB error: ' + err.message));
    zip.pipe(res);

    // mimetype must be the FIRST entry, uncompressed
    zip.append('application/epub+zip', { name: 'mimetype', store: true });

    // META-INF/container.xml
    zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`, { name: 'META-INF/container.xml' });

    // Stylesheet
    zip.append(`@page { margin: 1.4em }
body { font-family: Georgia, "Cormorant Garamond", serif; line-height: 1.55; color: #1a1815; }
h1 { font-style: italic; font-size: 2em; margin: 1em 0 .25em; }
h2 { font-style: italic; font-size: 1.5em; margin: 1.5em 0 .25em; color: #6a3a1a; }
.kicker { font-size: 0.7em; letter-spacing: .35em; text-transform: uppercase; color: #8a6d3b; }
.subhead { font-style: italic; color: #6e6356; margin: 0 0 1em; font-size: 1.1em; }
.pull { font-style: italic; font-size: 1.25em; color: #6a3a1a; border-left: 3px solid #8a6d3b; padding: .5em 1em; margin: 1em 0; }
.biz { margin-top: 1em; font-size: 0.9em; color: #6e6356; }
.toc-link { display: block; margin: .4em 0; }
.cover { text-align: center; padding: 4em 1em; }
.cover h1 { font-size: 3em; }
hr { border: none; border-top: 1px solid #d8cdb8; margin: 2em 0; }`, { name: 'OEBPS/style.css' });

    // Cover/title page
    zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${esc(title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body><div class="cover">
  <div class="kicker">Volume I · ${esc(stamp)}${cat ? ' · ' + esc(cat).toUpperCase() : ''}</div>
  <h1>The <em>Corridor</em></h1>
  <p class="subhead">A magazine of every business on Ventura Boulevard.</p>
  <p style="margin-top:3em;font-size:0.9em;color:#6e6356">${features.length} feature${features.length === 1 ? '' : 's'}</p>
</div></body></html>`, { name: 'OEBPS/cover.xhtml' });

    // TOC page
    zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Contents</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body><h1>Contents</h1>
${features.map((f, i) => `<a class="toc-link" href="ch${String(i+1).padStart(3,'0')}.xhtml">${esc(f.headline || f.biz_name)} · <span style="color:#8a6d3b">${esc(f.biz_name)}</span></a>`).join('\n')}
</body></html>`, { name: 'OEBPS/toc.xhtml' });

    // Each feature as a chapter
    features.forEach((f: any, i: number) => {
      const slug = `ch${String(i + 1).padStart(3, '0')}`;
      zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${esc(f.headline || f.biz_name)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>
<body>
  <div class="kicker">${esc(f.category_tag || 'feature')}</div>
  <h2>${esc(f.headline || f.biz_name)}</h2>
  ${f.subhead ? `<p class="subhead">${esc(f.subhead)}</p>` : ''}
  <p>${esc(f.editorial || '').replace(/\n+/g, '</p><p>')}</p>
  ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
  <div class="biz">${esc(f.biz_name)}<br/><span style="font-size:0.85em">${esc(f.biz_address || '')}${f.city ? ' · ' + esc(f.city) : ''}</span></div>
</body></html>`, { name: `OEBPS/${slug}.xhtml` });
    });

    // toc.ncx (EPUB 2 nav)
    zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
  <head><meta name="dtb:uid" content="${uid}"/><meta name="dtb:depth" content="1"/></head>
  <docTitle><text>${esc(title)}</text></docTitle>
  <navMap>
    <navPoint id="cover" playOrder="1"><navLabel><text>Cover</text></navLabel><content src="cover.xhtml"/></navPoint>
    <navPoint id="toc" playOrder="2"><navLabel><text>Contents</text></navLabel><content src="toc.xhtml"/></navPoint>
${features.map((f: any, i: number) => {
  const slug = `ch${String(i+1).padStart(3,'0')}`;
  return `    <navPoint id="${slug}" playOrder="${i+3}"><navLabel><text>${esc(f.headline || f.biz_name)}</text></navLabel><content src="${slug}.xhtml"/></navPoint>`;
}).join('\n')}
  </navMap>
</ncx>`, { name: 'OEBPS/toc.ncx' });

    // content.opf — package manifest + spine
    const manifestItems: string[] = [];
    const spineItems: string[] = [];
    manifestItems.push('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>');
    manifestItems.push('<item id="style" href="style.css" media-type="text/css"/>');
    manifestItems.push('<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>');
    manifestItems.push('<item id="toc" href="toc.xhtml" media-type="application/xhtml+xml"/>');
    spineItems.push('<itemref idref="cover"/>');
    spineItems.push('<itemref idref="toc"/>');
    features.forEach((_: any, i: number) => {
      const slug = `ch${String(i+1).padStart(3,'0')}`;
      manifestItems.push(`<item id="${slug}" href="${slug}.xhtml" media-type="application/xhtml+xml"/>`);
      spineItems.push(`<itemref idref="${slug}"/>`);
    });

    zip.append(`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="2.0">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
    <dc:title>${esc(title)}</dc:title>
    <dc:creator>The Corridor</dc:creator>
    <dc:identifier id="bookid">${uid}</dc:identifier>
    <dc:language>en</dc:language>
    <dc:date>${stamp}</dc:date>
    <dc:publisher>The Corridor</dc:publisher>
    <dc:description>${esc(features.length)} stories from Ventura Boulevard.</dc:description>
  </metadata>
  <manifest>${manifestItems.join('\n    ')}</manifest>
  <spine toc="ncx">${spineItems.join('\n    ')}</spine>
</package>`, { name: 'OEBPS/content.opf' });

    await zip.finalize();
  } catch (e: any) {
    if (!res.headersSent) res.status(500).send('EPUB build failed: ' + e.message);
  }
});

// Bulk-publish-all-reviewed: single-click curation finalization
app.post('/api/magazine/publish-reviewed', express.json(), async (req, res) => {
  try {
    const r = await query(
      `UPDATE magazine_features SET status = 'published', published_at = NOW()
       WHERE status = 'reviewed'
       RETURNING id`
    );
    res.json({ ok: true, published: r.rowCount });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Bulk-review (drafts → reviewed) — accepts an optional category filter.
app.post('/api/magazine/review-drafts', express.json(), async (req, res) => {
  try {
    const cat = req.body?.cat ? String(req.body.cat) : null;
    const minLen = parseInt(String(req.body?.min_len ?? '120'), 10) || 120;
    const params: any[] = [minLen];
    let where = `status = 'draft' AND length(editorial) >= $1`;
    if (cat) { params.push(cat); where += ` AND category_tag = $${params.length}`; }
    const r = await query(
      `UPDATE magazine_features SET status = 'reviewed', reviewed_at = NOW()
       WHERE ${where} RETURNING id`, params
    );
    res.json({ ok: true, reviewed: r.rowCount, scope: cat || 'all' });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// /sponsor/:id — owner-claim form prefilled with the business + feature. Submits
// via POST /api/sponsor/inquiry which emails Steve via George Gmail.
// /find — public-facing finder. Readers type "15303 suite 1600" or "the lawyer
// in encino" and get the closest match in the corridor.
// Different from /search.html (which searches editorial text); this hits names,
// addresses, suites, and city directly.
app.get('/api/find', async (req, res) => {
  try {
    const q = String(req.query.q || '').trim();
    if (q.length < 2) return res.json({ rows: [] });
    // Tokenize. Numeric tokens hit street_number / suite. Alphabetic tokens hit name + naics.
    const numTokens = (q.match(/\b\d{2,5}\b/g) || []);
    const wordTokens = (q.toLowerCase().match(/\b[a-z]{3,}\b/g) || []).filter(w => !['the','and','at','in','of','for','suite','ste','unit','blvd','ave','street','road'].includes(w));
    const r = await query(
      `SELECT bc.id AS business_id, bc.name, bc.address, bc.city, bc.suite, bc.bldg_address,
              mf.id AS feature_id, mf.headline, mf.category_tag,
              ${numTokens.map((n, i) => `(bc.address ILIKE '%${n}%')::int AS num_${i}`).join(', ') || `0 AS num_0`},
              ${wordTokens.map((w, i) => `(bc.name ILIKE '%${w.replace(/'/g, "''")}%' OR bc.address ILIKE '%${w.replace(/'/g, "''")}%' OR bc.city ILIKE '%${w.replace(/'/g, "''")}%' OR COALESCE(bc.naics,'') ILIKE '%${w.replace(/'/g, "''")}%')::int AS w_${i}`).join(', ') || `0 AS w_0`},
              GREATEST(${[
                ...numTokens.map((n, i) => `CASE WHEN bc.address ILIKE '%${n}%' THEN 4 ELSE 0 END`),
                ...wordTokens.map((w, i) => `CASE WHEN bc.name ILIKE '%${w.replace(/'/g, "''")}%' THEN 3 WHEN bc.address ILIKE '%${w.replace(/'/g, "''")}%' THEN 2 WHEN bc.city ILIKE '%${w.replace(/'/g, "''")}%' OR COALESCE(bc.naics,'') ILIKE '%${w.replace(/'/g, "''")}%' THEN 1 ELSE 0 END`)
              ].join(', ') || '0'}) AS score
       FROM v_building_canonical bc
       LEFT JOIN magazine_features mf ON mf.business_id = bc.id
       WHERE ${[...numTokens.map(n => `bc.address ILIKE '%${n}%'`),
                ...wordTokens.map(w => `(bc.name ILIKE '%${w.replace(/'/g, "''")}%' OR bc.address ILIKE '%${w.replace(/'/g, "''")}%' OR bc.city ILIKE '%${w.replace(/'/g, "''")}%' OR COALESCE(bc.naics,'') ILIKE '%${w.replace(/'/g, "''")}%')`)].join(' OR ') || 'false'}
       ORDER BY score DESC, mf.id IS NULL ASC, bc.name ASC
       LIMIT 30`
    );
    res.json({ q, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// /api/magazine/:id/social-pack.json — JSON shape of the ad-pack page.
// Programmatic consumers (IG agent, bots) get short/medium/long brief variants
// + tags + share-card url + audio url + permalink in one call.
app.get('/api/magazine/:id/social-pack.json', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              mf.published_at,
              b.name AS biz_name, b.address AS biz_address, b.city, b.zip
       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
       WHERE mf.id = $1`, [id]
    );
    if (r.rowCount === 0) return res.status(404).json({ error: 'not found' });
    const f = r.rows[0];
    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
    const tagline = String(f.subhead || (f.editorial || '').split(/[.!?]\s/)[0] || '').slice(0, 90);
    const short  = String(f.subhead || tagline).slice(0, 90);
    const medium = (() => {
      const lede = String(f.editorial || '').split(/(?<=[.!?])\s+/).slice(0, 2).join(' ');
      return lede.slice(0, 280);
    })();
    const long = String(f.editorial || '').slice(0, 540).replace(/\s+\S*$/, '') + '…';
    const tags = ['#TheCorridor', '#VenturaBoulevard', '#LosAngeles'];
    if (f.category_tag) tags.push('#' + String(f.category_tag).replace(/[^a-z0-9]/gi, ''));
    if (f.city) tags.push('#' + String(f.city).replace(/\s+/g, ''));

    res.json({
      feature_id: f.id,
      headline: f.headline,
      biz_name: f.biz_name,
      category: f.category_tag,
      city: f.city,
      permalink: `${baseUrl}/magazine/${id}`,
      ad_pack_url: `${baseUrl}/magazine/${id}/ad-pack.html`,
      share_card: `${baseUrl}/share/${id}`,
      audio_m4a: `${baseUrl}/api/magazine/${id}/audio.m4a`,
      tags,
      variants: {
        short:       { text: short,                      chars: short.length,                       fits: ['twitter','x','gbp','sms'] },
        medium:      { text: medium,                     chars: medium.length,                      fits: ['facebook','instagram','linkedin'] },
        medium_tags: { text: medium + '\n\n' + tags.join(' '), chars: medium.length + tags.join(' ').length + 2, fits: ['instagram','facebook'] },
        long:        { text: long,                       chars: long.length,                        fits: ['newsletter','about-page','press-kit'] },
        pull_quote:  f.pull_quote ? { text: `"${f.pull_quote}"`, chars: f.pull_quote.length + 2, fits: ['hero-callout','standalone-graphic'] } : null,
      },
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// /magazine/:id/ad-pack.html — copy-ready ad-brief variants for the feature.
// Three lengths: short (Twitter/X / GBP), medium (FB/IG), long (full editorial extract).
// Steve hands this URL to a business owner so they can pick the brief that suits.
app.get('/magazine/:id/ad-pack.html', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
            b.name AS biz_name, b.address AS biz_address, b.city
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`, [id]
  );
  if (r.rowCount === 0) return res.status(404).send('not found');
  const f = r.rows[0];
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  // Three brief variants — all derived from existing fields, no new gen needed.
  const tagline = String(f.subhead || (f.editorial || '').split(/[.!?]\s/)[0] || '').slice(0, 90);
  const short  = String(f.subhead || tagline).slice(0, 90);
  const medium = (() => {
    const lede = String(f.editorial || '').split(/(?<=[.!?])\s+/).slice(0, 2).join(' ');
    return lede.slice(0, 280);
  })();
  const long = String(f.editorial || '').slice(0, 540).replace(/\s+\S*$/, '') + '…';
  const tags = ['#TheCorridor', '#VenturaBoulevard', '#LosAngeles'];
  if (f.category_tag) tags.push('#' + String(f.category_tag).replace(/[^a-z0-9]/gi, ''));

  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Ad pack · ${esc(f.headline || f.biz_name)}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.55;padding:36px 24px;max-width:780px;margin:0 auto}
.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:42px;margin:8px 0 6px;color:var(--ink);line-height:1.05}
.biz{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink-mute);margin-bottom:28px;font-size:16px}
.variant{margin:24px 0;padding:24px 22px;border:1px solid var(--rule);background:rgba(184,153,104,0.04);position:relative}
.variant h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--metal);margin:0 0 4px;font-size:22px;font-weight:500}
.variant .where{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:16px}
.variant .where b{color:var(--metal-glow)}
.variant .text{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;line-height:1.55;color:var(--ink);white-space:pre-wrap;margin-bottom:14px}
.variant .meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-mute);margin-top:6px}
.variant .copy{position:absolute;top:14px;right:14px;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);background:transparent;border:1px solid var(--rule);padding:6px 12px;cursor:pointer;font-family:'Inter',sans-serif}
.variant .copy:hover{border-color:var(--metal)}
.variant .copy.ok{color:var(--accent);border-color:var(--accent)}
.tags{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--metal);margin-top:18px}
.foot{margin-top:36px;font-size:11px;color:var(--ink-mute);text-align:center;border-top:1px solid var(--rule);padding-top:18px}
.foot a{color:var(--metal);text-decoration:none}
@media print{.copy{display:none}body{padding:8px}}
</style></head><body>
<div class="kicker">The Corridor · ad pack</div>
<h1>${esc(f.headline || f.biz_name)}</h1>
<div class="biz">${esc(f.biz_name)}${f.biz_address ? ' · ' + esc(f.biz_address) : ''}${f.city ? ' · ' + esc(f.city) : ''}</div>

<div class="variant">
  <h3>Short</h3>
  <div class="where">For: <b>Twitter / X · Google Business Profile · SMS</b></div>
  <button class="copy" onclick="copyText(this, ${JSON.stringify(short).replace(/"/g, '&quot;')})">Copy</button>
  <div class="text">${esc(short)}</div>
  <div class="meta">${short.length} chars</div>
</div>

<div class="variant">
  <h3>Medium</h3>
  <div class="where">For: <b>Facebook · Instagram caption · LinkedIn</b></div>
  <button class="copy" onclick="copyText(this, ${JSON.stringify(medium + '\n\n' + tags.join(' ')).replace(/"/g, '&quot;')})">Copy</button>
  <div class="text">${esc(medium)}</div>
  <div class="tags">${tags.join(' ')}</div>
  <div class="meta">${medium.length} chars + tags</div>
</div>

<div class="variant">
  <h3>Long</h3>
  <div class="where">For: <b>Newsletter · About-us page · Press kit</b></div>
  <button class="copy" onclick="copyText(this, ${JSON.stringify(long).replace(/"/g, '&quot;')})">Copy</button>
  <div class="text">${esc(long)}</div>
  <div class="meta">${long.length} chars</div>
</div>

${f.pull_quote ? `<div class="variant">
  <h3>Pull quote</h3>
  <div class="where">For: <b>Hero callout · Stand-alone graphic</b></div>
  <button class="copy" onclick="copyText(this, ${JSON.stringify('"' + f.pull_quote + '"').replace(/"/g, '&quot;')})">Copy</button>
  <div class="text" style="font-size:24px;color:var(--accent)">"${esc(f.pull_quote)}"</div>
  <div class="meta">${(f.pull_quote || '').length} chars</div>
</div>` : ''}

<div class="foot">
  Source: <a href="/magazine/${id}">/magazine/${id}</a> ·
  <a href="/share/${id}">share card</a> ·
  <a href="/api/magazine/${id}/audio.m4a">audio</a> ·
  <a href="javascript:window.print()">print</a>
</div>

<script>
function copyText(btn, txt) {
  navigator.clipboard.writeText(txt).then(() => {
    const o = btn.textContent;
    btn.textContent = '✓ copied';
    btn.classList.add('ok');
    setTimeout(() => { btn.textContent = o; btn.classList.remove('ok'); }, 1500);
  });
}
</script>
</body></html>`);
});

// /magazine/:id/print.html — minimal print-only single-feature, no toolbar/carousels
app.get('/magazine/:id/print.html', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
            mf.published_at, mf.generated_at, mf.model,
            b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
            b.raw->>'primary_naics_description' AS naics
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`,
    [id]
  );
  if (r.rowCount === 0) return res.status(404).send('not found');
  const f = r.rows[0];
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>${esc(f.headline || f.biz_name)} — The Corridor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
@page { size: Letter; margin: 0.75in 0.75in 0.5in; }
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Cormorant Garamond',serif;color:#1a1815;line-height:1.55;background:white;font-weight:400}
.kicker{font-family:'Inter',sans-serif;font-size:9pt;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500;margin-bottom:18px;border-bottom:0.5pt solid #d8cdb8;padding-bottom:8px}
h1{font-style:italic;font-weight:500;font-size:36pt;line-height:1.0;margin-bottom:10pt;letter-spacing:-0.01em}
.subhead{font-style:italic;font-weight:300;font-size:14pt;line-height:1.35;color:#6e6356;margin-bottom:18pt}
.lede{font-weight:400;font-size:12pt;line-height:1.65;margin-bottom:12pt;text-align:justify;hyphens:auto}
.lede::first-letter{font-weight:600;font-size:36pt;line-height:0.85;float:left;margin:6pt 8pt 0 0;color:#6a3a1a}
.pull{font-style:italic;font-weight:400;font-size:14pt;line-height:1.3;color:#6a3a1a;border-left:2pt solid #8a6d3b;padding-left:14pt;margin:18pt 0}
.colophon{margin-top:36pt;padding-top:14pt;border-top:0.5pt solid #d8cdb8;font-family:'Inter',sans-serif;font-size:8pt;color:#6e6356;letter-spacing:.04em;display:grid;grid-template-columns:1fr 1fr;gap:24pt}
.colophon dt{color:#8a6d3b;font-size:7pt;letter-spacing:.32em;text-transform:uppercase;margin-bottom:2pt}
.colophon dd{font-family:'Cormorant Garamond',serif;font-size:11pt;font-style:italic;color:#1a1815}
.foot{margin-top:24pt;text-align:center;font-family:'Inter',sans-serif;font-size:8pt;letter-spacing:.32em;text-transform:uppercase;color:#6e6356}
.foot em{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:11pt;color:#8a6d3b;letter-spacing:0;text-transform:none}
</style></head><body>
<div class="kicker">The Corridor · ${esc(f.category_tag || 'feature')}</div>
<h1>${esc(f.headline || '(untitled)')}</h1>
<p class="subhead">${esc(f.subhead || '')}</p>
<p class="lede">${esc(f.editorial || '')}</p>
${f.pull_quote ? `<p class="pull">"${esc(f.pull_quote)}"</p>` : ''}
<dl class="colophon">
  <div><dt>Business</dt><dd>${esc(f.biz_name)}</dd></div>
  <div><dt>Address</dt><dd>${esc(f.biz_address || '')}, ${esc(f.city || '')} ${esc(f.zip || '')}</dd></div>
  ${f.naics ? `<div><dt>Trade</dt><dd>${esc(f.naics)}</dd></div>` : ''}
  <div><dt>Issue</dt><dd>${f.published_at ? new Date(f.published_at).toLocaleDateString('en-US',{month:'long',year:'numeric'}) : 'Volume I'}</dd></div>
</dl>
<div class="foot">The <em>Corridor</em> · Volume I · ${new Date().getFullYear()}</div>
<script>setTimeout(() => window.print(), 250);</script>
</body></html>`);
});

// /buildings/:bldg/issue — mini-issue showing every feature for one building.
// Powers a future "this building's tenants" landing page; permalinkable.
app.get('/buildings/:bldg/issue', async (req, res) => {
  try {
    const bldg = decodeURIComponent(req.params.bldg);
    const r = await query(
      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
              b.name AS biz_name, bc.suite, b.city
       FROM v_building_canonical bc
       JOIN businesses b ON b.id = bc.id
       LEFT JOIN magazine_features mf ON mf.business_id = bc.id
       WHERE bc.bldg_address = $1
       ORDER BY mf.id IS NULL ASC, bc.suite NULLS LAST, b.name`,
      [bldg]
    );
    const featured = r.rows.filter((x: any) => x.id);
    const unfeatured = r.rows.filter((x: any) => !x.id);
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>${esc(bldg)} — The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.55}
header{text-align:center;padding:48px 24px 28px;border-bottom:4px double var(--ink)}
header .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink);letter-spacing:-0.02em}
header h1 em{font-style:normal;color:var(--metal)}
header .deck{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:16px;color:var(--ink-mute);margin-top:8px}
main{max-width:780px;margin:0 auto;padding:36px 24px 80px}
.feature{margin-bottom:36px;padding-bottom:24px;border-bottom:1px solid var(--rule)}
.feature:last-of-type{border-bottom:none}
.feature .cat{display:inline-block;background:var(--ink);color:var(--paper);padding:3px 10px;font-size:9px;letter-spacing:.32em;text-transform:uppercase;font-weight:500;margin-bottom:8px}
.feature .suite{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);margin-left:8px}
.feature h2{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:28px;line-height:1.05;margin:0 0 6px}
.feature h2 a{color:var(--ink);text-decoration:none}
.feature .subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:15px;color:var(--ink-mute);margin:0 0 14px}
.feature .biz{font-size:11px;letter-spacing:.06em;color:var(--ink-mute);font-family:'JetBrains Mono',monospace}
.unfeatured{margin-top:36px;padding:18px;background:rgba(184,153,104,0.06);border:1px solid var(--rule);font-size:13px}
.unfeatured h3{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:18px;color:var(--metal);margin:0 0 10px}
.unfeatured ul{list-style:none;padding:0;margin:0}
.unfeatured li{padding:5px 0;border-top:1px dotted var(--rule);display:flex;justify-content:space-between;font-size:13px;color:var(--ink-mute)}
.unfeatured li:first-child{border-top:none}
.unfeatured li b{color:var(--ink);font-weight:500}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
</style></head><body>
<div class="toolbar">
  <a href="/issue">All issues</a>
  <a href="/buildings.html?bldg=${encodeURIComponent(bldg)}">Roster</a>
</div>
<header>
  <div class="kicker">The Corridor · in this building</div>
  <h1>${esc(bldg)}</h1>
  <div class="deck">${featured.length} feature${featured.length === 1 ? '' : 's'} · ${r.rowCount} tenant${r.rowCount === 1 ? '' : 's'} on file</div>
</header>
<main>
${featured.length === 0 ? `<p style="text-align:center;font-style:italic;color:var(--ink-mute);padding:60px 0">No features yet for this building.</p>` :
featured.map((f: any) => `
  <article class="feature">
    <div>
      <span class="cat">${esc(f.category_tag || 'feature')}</span>
      ${f.suite ? `<span class="suite">Suite ${esc(f.suite)}</span>` : ''}
    </div>
    <h2><a href="/magazine/${f.id}">${esc(f.headline || f.biz_name)}</a></h2>
    <div class="subhead">${esc(f.subhead || '')}</div>
    <div class="biz">${esc(f.biz_name)}${f.suite ? ' · #' + esc(f.suite) : ''} · ${esc(f.city || '')}</div>
  </article>
`).join('')}
${unfeatured.length > 0 ? `<div class="unfeatured">
  <h3>Also at this address · not yet featured</h3>
  <ul>${unfeatured.map((u: any) => `<li><b>${esc(u.biz_name)}</b><span>${u.suite ? '#' + esc(u.suite) : ''}</span></li>`).join('')}</ul>
</div>` : ''}
</main>
</body></html>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

app.get('/sponsor/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.id, mf.headline, mf.subhead, mf.category_tag,
            b.name AS biz_name, b.address AS biz_address, b.city,
            COALESCE((be.ad_signals->>'paid_ads_count')::int, 0) AS ads
     FROM magazine_features mf
     JOIN businesses b ON b.id = mf.business_id
     LEFT JOIN business_enrichment be ON be.business_id = mf.business_id
     WHERE mf.id = $1`, [id]
  );
  if (r.rowCount === 0) return res.status(404).send('feature not found');
  const f = r.rows[0];
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>Sponsor — ${esc(f.biz_name)} · The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.55}
header{text-align:center;padding:48px 24px 28px;border-bottom:4px double var(--ink)}
header .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink);letter-spacing:-0.02em}
header h1 em{font-style:normal;color:var(--metal)}
main{max-width:680px;margin:0 auto;padding:36px 24px 80px}
.feature-summary{padding:18px 22px;background:rgba(184,153,104,0.06);border:1px solid var(--rule);margin-bottom:36px}
.feature-summary .h{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:24px;margin:0 0 4px;color:var(--ink)}
.feature-summary .biz{font-size:13px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace}
.feature-summary .ads{display:inline-block;background:var(--accent);color:var(--paper);padding:3px 10px;font-size:9px;letter-spacing:.3em;text-transform:uppercase;font-weight:500;margin-top:8px}
form label{display:block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:18px;margin-bottom:6px;font-weight:500}
form input, form textarea, form select{width:100%;background:transparent;border:1px solid var(--rule);color:var(--ink);padding:10px 12px;font-family:'Inter',sans-serif;font-size:14px;border-radius:0}
form input:focus, form textarea:focus, form select:focus{outline:none;border-color:var(--metal)}
form textarea{min-height:90px;resize:vertical}
form .tier-pick{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:6px}
form .tier-pick label{cursor:pointer;padding:14px;border:1px solid var(--rule);text-align:center;display:block;text-transform:none;letter-spacing:0;color:var(--ink);font-size:13px;font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;margin:0}
form .tier-pick label.sel{border-color:var(--metal);background:rgba(184,153,104,0.08);color:var(--accent);font-weight:500}
form .tier-pick label .price{display:block;font-family:'Inter',sans-serif;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px;font-weight:400}
form .tier-pick input[type=radio]{display:none}
form .submit{margin-top:32px;background:var(--ink);color:var(--paper);border:none;padding:14px 28px;font-size:11px;letter-spacing:.32em;text-transform:uppercase;font-weight:500;cursor:pointer;font-family:'Inter',sans-serif}
form .submit:hover{background:var(--accent)}
form .submit:disabled{opacity:0.4;cursor:not-allowed}
.success{padding:24px;background:#dfe9d9;border:1px solid #6a9b73;color:#2a4a2a;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;margin-top:24px}
.note{font-size:12px;color:var(--ink-mute);margin-top:6px;line-height:1.45}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
</style></head><body>
<div class="toolbar">
  <a href="/magazine/${id}">← Read feature</a>
  <a href="/rate-card.html">Rates</a>
</div>
<header>
  <div class="kicker">The Corridor · sponsorship inquiry</div>
  <h1>Make this a <em>spread</em></h1>
</header>
<main>
  <div class="feature-summary">
    <div class="h">${esc(f.headline || f.biz_name)}</div>
    <div class="biz">${esc(f.biz_name)} · ${esc(f.biz_address || '')} · ${esc(f.city || '')} · ${esc(f.category_tag || '')}</div>
    ${f.ads > 0 ? `<div class="ads">$ ${f.ads}× paid-ad pixels detected</div>` : ''}
  </div>
  <form id="form">
    <input type="hidden" name="feature_id" value="${id}">
    <input type="hidden" name="biz_name" value="${esc(f.biz_name)}">

    <label>Tier</label>
    <div class="tier-pick">
      <label data-tier="spread" class="sel"><input type="radio" name="tier" value="spread" checked>Spread<span class="price">$240/issue</span></label>
      <label data-tier="cover"><input type="radio" name="tier" value="cover">Cover<span class="price">$1,800/issue</span></label>
      <label data-tier="ask"><input type="radio" name="tier" value="ask">Just curious<span class="price">no commitment</span></label>
    </div>

    <label>Your name</label>
    <input type="text" name="contact_name" placeholder="Maria Rivera" required>

    <label>Email</label>
    <input type="email" name="contact_email" placeholder="maria@${esc((f.biz_name || '').toLowerCase().replace(/[^a-z]/g, ''))}example.com" required>

    <label>Phone (optional)</label>
    <input type="tel" name="contact_phone" placeholder="(818) 555-0100">

    <label>What do you want to highlight?</label>
    <textarea name="notes" placeholder="A new chef, a renovation, a community partnership — anything you'd like the editor to know."></textarea>
    <div class="note">Don't worry about polished copy. Steve writes the editorial; you give the spark.</div>

    <button type="submit" class="submit">Send inquiry</button>
  </form>
  <div id="result"></div>
</main>
<script>
document.querySelectorAll('.tier-pick label').forEach(l => l.addEventListener('click', () => {
  document.querySelectorAll('.tier-pick label').forEach(x => x.classList.remove('sel'));
  l.classList.add('sel');
}));
document.getElementById('form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const btn = e.target.querySelector('button.submit');
  btn.disabled = true; btn.textContent = 'Sending…';
  const data = Object.fromEntries(new FormData(e.target));
  try {
    const r = await fetch('/api/sponsor/inquiry', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    if (!r.ok) throw new Error(await r.text());
    document.getElementById('form').style.display = 'none';
    document.getElementById('result').innerHTML = '<div class="success">Sent. Steve will reply within one business day to ' + (data.contact_email || 'you') + '.</div>';
  } catch (err) {
    btn.disabled = false; btn.textContent = 'Send inquiry';
    alert('Failed: ' + err.message);
  }
});
</script>
</body></html>`);
});

// Sponsor inquiry intake — emails Steve via George Gmail
app.post('/api/sponsor/inquiry', express.json(), async (req, res) => {
  try {
    const b = req.body || {};
    if (!b.contact_email || !b.contact_name) return res.status(400).json({ error: 'name + email required' });
    const escEm = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const html = `<div style="font-family:Georgia,serif;max-width:560px;color:#1a1a1a;padding:24px;background:#faf6ee">
<h2 style="font-family:Georgia,serif;font-style:italic;font-size:28px;color:#6a3a1a;margin:0 0 8px">New sponsor inquiry</h2>
<div style="font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:#8a6d3b;margin-bottom:20px">The Corridor · ${new Date().toLocaleString('en-US',{dateStyle:'long',timeStyle:'short'})}</div>
<table style="border-collapse:collapse;font-size:14px">
<tr><td style="padding:6px 14px 6px 0;color:#888;width:140px">Tier</td><td><b style="text-transform:uppercase;letter-spacing:.18em;color:#6a3a1a">${escEm(b.tier || 'ask')}</b></td></tr>
<tr><td style="padding:6px 14px 6px 0;color:#888">Business</td><td><b>${escEm(b.biz_name || 'unknown')}</b></td></tr>
<tr><td style="padding:6px 14px 6px 0;color:#888">Feature ID</td><td><a href="http://127.0.0.1:9780/magazine/${escEm(b.feature_id)}">/magazine/${escEm(b.feature_id)}</a></td></tr>
<tr><td style="padding:6px 14px 6px 0;color:#888">Contact</td><td>${escEm(b.contact_name)}</td></tr>
<tr><td style="padding:6px 14px 6px 0;color:#888">Email</td><td><a href="mailto:${escEm(b.contact_email)}">${escEm(b.contact_email)}</a></td></tr>
${b.contact_phone ? `<tr><td style="padding:6px 14px 6px 0;color:#888">Phone</td><td><a href="tel:${escEm(b.contact_phone)}">${escEm(b.contact_phone)}</a></td></tr>` : ''}
</table>
${b.notes ? `<div style="margin-top:24px;padding:14px 18px;background:rgba(184,153,104,0.1);border-left:3px solid #8a6d3b;font-style:italic;font-size:14px;line-height:1.5">${escEm(b.notes)}</div>` : ''}
<div style="margin-top:24px;font-size:11px;color:#888">Reply directly to ${escEm(b.contact_email)} to start the back-and-forth.</div>
</div>`;
    const georgeAuth = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
    const resp = await fetch('http://localhost:9850/api/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: georgeAuth },
      body: JSON.stringify({
        to: 'steve@designerwallcoverings.com',
        subject: `🪶 Corridor sponsor inquiry · ${b.biz_name || 'unknown'} · ${b.tier || 'ask'}`,
        body: html,
        isHtml: true,
        account: 'info'
      })
    });
    const out = await resp.json().catch(() => ({}));
    await query(
      `INSERT INTO sponsor_inquiries (feature_id, biz_name, contact_name, contact_email, contact_phone, tier, notes, email_message_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
      [b.feature_id, b.biz_name, b.contact_name, b.contact_email, b.contact_phone, b.tier, b.notes, out.messageId || null]
    );
    res.json({ ok: resp.ok, messageId: out.messageId });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Reader feedback intake — typo reports, business-owner corrections, story tips.
// Inserts to PG + fires a George Gmail ping so Steve sees it inbox-side.
app.post('/api/feedback', express.json(), async (req, res) => {
  try {
    const b = req.body || {};
    const kind = String(b.kind || 'note').slice(0, 32);
    if (!b.body || String(b.body).length < 4) return res.status(400).json({ error: 'body required (min 4 chars)' });
    if (String(b.body).length > 5000) return res.status(400).json({ error: 'body too long (max 5000 chars)' });
    if (!['note','typo','correction','tip','complaint','praise'].includes(kind)) return res.status(400).json({ error: 'unknown kind' });
    const r = await query(
      `INSERT INTO reader_feedback (feature_id, kind, body, contact, source_path)
       VALUES ($1, $2, $3, $4, $5) RETURNING id`,
      [b.feature_id || null, kind, b.body, b.contact || null, b.source_path || null]
    );
    const fbId = r.rows[0].id;

    // Notify Steve only for real correction-class kinds; skip pure "praise"/"note" to avoid inbox noise.
    const NOTIFY_KINDS = new Set(['typo', 'correction', 'tip', 'complaint']);
    if (NOTIFY_KINDS.has(kind)) {
      // Look up the feature for context (best-effort; never block on this)
      let context: { headline?: string; biz?: string } = {};
      if (b.feature_id) {
        try {
          const c = await query(
            `SELECT mf.headline, b.name AS biz FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id WHERE mf.id = $1`,
            [b.feature_id]
          );
          if (c.rowCount) context = c.rows[0];
        } catch {}
      }
      const escEm = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
      const html = `<div style="font-family:Georgia,serif;max-width:560px;color:#1a1a1a;padding:24px;background:#faf6ee">
<h2 style="font-family:Georgia,serif;font-style:italic;font-size:26px;color:#6a3a1a;margin:0 0 8px">Reader ${escEm(kind)}</h2>
<div style="font-size:11px;letter-spacing:.3em;text-transform:uppercase;color:#8a6d3b;margin-bottom:20px">The Corridor · feedback #${fbId} · ${new Date().toLocaleString('en-US',{dateStyle:'long',timeStyle:'short'})}</div>
<table style="border-collapse:collapse;font-size:14px;margin-bottom:20px">
${context.headline ? `<tr><td style="padding:6px 14px 6px 0;color:#888;width:120px">Feature</td><td><a href="http://127.0.0.1:9780/magazine/${escEm(b.feature_id)}">${escEm(context.headline)}</a></td></tr>` : ''}
${context.biz ? `<tr><td style="padding:6px 14px 6px 0;color:#888">Business</td><td>${escEm(context.biz)}</td></tr>` : ''}
${b.contact ? `<tr><td style="padding:6px 14px 6px 0;color:#888">Reader</td><td><a href="mailto:${escEm(b.contact)}">${escEm(b.contact)}</a></td></tr>` : '<tr><td style="padding:6px 14px 6px 0;color:#888">Reader</td><td><i>anonymous</i></td></tr>'}
${b.source_path ? `<tr><td style="padding:6px 14px 6px 0;color:#888">From</td><td><code style="font-size:12px;color:#6e6356">${escEm(b.source_path)}</code></td></tr>` : ''}
</table>
<div style="padding:14px 18px;background:rgba(184,153,104,0.1);border-left:3px solid #8a6d3b;font-style:italic;font-size:15px;line-height:1.5">${escEm(b.body)}</div>
<div style="margin-top:24px;font-size:11px;color:#888">Triage at <a href="http://127.0.0.1:9780/feedback.html">/feedback.html</a> · mark reviewed when handled.</div>
</div>`;
      try {
        const georgeAuth = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
        await fetch('http://localhost:9850/api/send', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Authorization: georgeAuth },
          body: JSON.stringify({
            to: 'steve@designerwallcoverings.com',
            subject: `🪶 Corridor reader ${kind}${context.headline ? ' · ' + context.headline.slice(0, 60) : ''}`,
            body: html,
            isHtml: true,
            account: 'info'
          })
        });
      } catch (e) { /* never block feedback intake on email send */ }
    }

    res.json({ ok: true, id: fbId });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/feedback', async (_req, res) => {
  try {
    const r = await query(
      `SELECT f.id, f.feature_id, f.kind, f.body, f.contact, f.received_at, f.reviewed,
              mf.headline, b.name AS biz_name
       FROM reader_feedback f
       LEFT JOIN magazine_features mf ON mf.id = f.feature_id
       LEFT JOIN businesses b ON b.id = mf.business_id
       ORDER BY f.received_at DESC LIMIT 100`
    );
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.patch('/api/feedback/:id', express.json(), async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const reviewed = !!req.body?.reviewed;
    await query(`UPDATE reader_feedback SET reviewed = $1 WHERE id = $2`, [reviewed, id]);
    res.json({ ok: true, reviewed });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.patch('/api/sponsor/inquiries/:id', express.json(), async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const status = String(req.body?.status || '').trim();
    if (!['new','responded','closed','spam'].includes(status)) return res.status(400).json({ error: 'status must be new|responded|closed|spam' });
    await query(`UPDATE sponsor_inquiries SET status = $1 WHERE id = $2`, [status, id]);
    res.json({ ok: true, status });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/sponsor/inquiries', async (_req, res) => {
  try {
    const r = await query(
      `SELECT id, feature_id, biz_name, contact_name, contact_email, tier, notes, received_at, status
       FROM sponsor_inquiries ORDER BY received_at DESC LIMIT 50`
    );
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Issue cover picker — Steve nominates ONE feature per month as the issue cover ──
app.get('/api/issues', async (_req, res) => {
  try {
    const r = await query(`
      SELECT i.issue_month, i.cover_feature_id, i.cover_caption, i.cover_kicker, i.cover_set_at, i.published_at,
             mf.headline AS cover_headline, mf.subhead AS cover_subhead, mf.category_tag AS cover_category,
             b.name AS cover_biz
      FROM magazine_issues i
      LEFT JOIN magazine_features mf ON mf.id = i.cover_feature_id
      LEFT JOIN businesses b ON b.id = mf.business_id
      ORDER BY i.issue_month DESC
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/issues/current', async (_req, res) => {
  try {
    const month = new Date().toISOString().slice(0, 7);
    const r = await query(`
      SELECT i.issue_month, i.cover_feature_id, i.cover_caption, i.cover_kicker, i.cover_set_at,
             mf.headline AS cover_headline, mf.subhead AS cover_subhead, mf.category_tag AS cover_category, mf.pull_quote AS cover_pull_quote,
             b.name AS cover_biz, b.address AS cover_biz_address, b.city AS cover_biz_city
      FROM magazine_issues i
      LEFT JOIN magazine_features mf ON mf.id = i.cover_feature_id
      LEFT JOIN businesses b ON b.id = mf.business_id
      WHERE i.issue_month = $1
    `, [month]);
    res.json({ month, issue: r.rows[0] || { issue_month: month, cover_feature_id: null } });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Cover image upload — multipart, max 8MB, jpg/png/webp
import multer from 'multer';
import * as _fs_for_covers from 'node:fs';
const COVERS_DIR = path.resolve(__dirname, '../../data/covers');
if (!_fs_for_covers.existsSync(COVERS_DIR)) _fs_for_covers.mkdirSync(COVERS_DIR, { recursive: true });
const _coverUpload = multer({
  storage: multer.diskStorage({
    destination: (_req, _file, cb) => cb(null, COVERS_DIR),
    filename: (req, file, cb) => {
      const month = String(req.params.month || '').match(/^\d{4}-\d{2}$/) ? req.params.month : 'invalid';
      const ext = ({ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' } as any)[file.mimetype] || 'bin';
      cb(null, `${month}.${ext}`);
    },
  }),
  limits: { fileSize: 8 * 1024 * 1024 },
  fileFilter: (_req, file, cb) => {
    if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.mimetype)) return cb(new Error('jpg/png/webp only'));
    cb(null, true);
  },
});
app.post('/api/issues/:month/cover-image', _coverUpload.single('image'), async (req, res) => {
  try {
    const month = String(req.params.month).match(/^\d{4}-\d{2}$/) ? req.params.month : null;
    if (!month) return res.status(400).json({ error: 'month must be YYYY-MM' });
    if (!req.file) return res.status(400).json({ error: 'image file required (field name: image)' });
    const fname = req.file.filename;
    await query(`
      INSERT INTO magazine_issues (issue_month, cover_image_path, cover_set_at)
      VALUES ($1, $2, NOW())
      ON CONFLICT (issue_month) DO UPDATE SET cover_image_path = EXCLUDED.cover_image_path, cover_set_at = NOW()
    `, [month, fname]);
    res.json({ ok: true, month, image: `/covers/${fname}` });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.delete('/api/issues/:month/cover-image', async (req, res) => {
  try {
    const month = String(req.params.month).match(/^\d{4}-\d{2}$/) ? req.params.month : null;
    if (!month) return res.status(400).json({ error: 'month must be YYYY-MM' });
    const r = await query(`SELECT cover_image_path FROM magazine_issues WHERE issue_month = $1`, [month]);
    const old = r.rows[0]?.cover_image_path;
    if (old) {
      try { _fs_for_covers.unlinkSync(path.join(COVERS_DIR, old)); } catch {}
    }
    await query(`UPDATE magazine_issues SET cover_image_path = NULL WHERE issue_month = $1`, [month]);
    res.json({ ok: true });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.patch('/api/issues/:month', express.json(), async (req, res) => {
  try {
    const month = String(req.params.month).match(/^\d{4}-\d{2}$/) ? req.params.month : null;
    if (!month) return res.status(400).json({ error: 'month must be YYYY-MM' });
    const b = req.body || {};
    const fid = b.cover_feature_id == null ? null : parseInt(String(b.cover_feature_id), 10);
    const caption = b.cover_caption == null ? null : String(b.cover_caption).slice(0, 240);
    const kicker = b.cover_kicker == null ? null : String(b.cover_kicker).slice(0, 80);
    await query(`
      INSERT INTO magazine_issues (issue_month, cover_feature_id, cover_caption, cover_kicker, cover_set_at)
      VALUES ($1, $2, $3, $4, NOW())
      ON CONFLICT (issue_month) DO UPDATE
        SET cover_feature_id = EXCLUDED.cover_feature_id,
            cover_caption    = EXCLUDED.cover_caption,
            cover_kicker     = EXCLUDED.cover_kicker,
            cover_set_at     = EXCLUDED.cover_set_at
    `, [month, fid, caption, kicker]);
    res.json({ ok: true, month, cover_feature_id: fid });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// /api/magazine/:id/embed.html — iframe-friendly mini-card for blog/IG sidebars/Steve's other sites
app.get('/api/magazine/:id/embed.html', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.id, mf.headline, mf.subhead, mf.category_tag, mf.editorial,
            b.name AS biz_name, b.address AS biz_address, b.city
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`,
    [id]
  );
  if (r.rowCount === 0) return res.status(404).send('not found');
  const f = r.rows[0];
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  const cat = f.category_tag || 'shop';
  const grad: Record<string,string> = {
    restaurant:   '#8a3a1a,#c47b5a',
    professional: '#2d3a4a,#5a6c7d',
    medical:      '#2a5a4a,#5a8a7a',
    beauty:       '#6a3a5a,#a76a8a',
    salon:        '#5a2a4a,#8a5a7a',
    fitness:      '#2a3a1a,#5a7a3a',
    shop:         '#4a2a1a,#8a5a3a',
    'real-estate':'#1a3a4a,#3a6a8a',
    hospitality:  '#4a3a1a,#8a6a3a',
    automotive:   '#2a2a2a,#5a5a5a',
  };
  const [c1, c2] = (grad[cat] || grad.shop).split(',');
  const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.setHeader('X-Frame-Options', 'ALLOWALL');
  res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>${esc(f.headline)} — embed</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{margin:0;background:transparent;font-family:'Inter',sans-serif;font-weight:300;line-height:1.5;color:#1a1815}
.card{display:block;background:#faf6ee;border:1px solid #d8cdb8;text-decoration:none;color:#1a1815;max-width:480px;overflow:hidden}
.card .photo{aspect-ratio:16/9;background:linear-gradient(135deg,${c1} 0%,${c2} 100%);position:relative;display:flex;align-items:flex-end;justify-content:flex-start;padding:14px}
.card .photo .cat{background:rgba(0,0,0,0.4);color:white;padding:3px 10px;font-size:9px;letter-spacing:.3em;text-transform:uppercase;font-weight:600}
.card .body{padding:16px 18px}
.card h2{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:22px;line-height:1.1;margin:0 0 6px;color:#1a1815}
.card .subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:#6e6356;margin-bottom:10px;line-height:1.4}
.card .biz{font-size:11px;color:#888;letter-spacing:.04em}
.card .biz strong{color:#1a1815;font-weight:500}
.card .footer{padding:8px 18px;border-top:1px solid #d8cdb8;background:rgba(184,153,104,0.04);font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:#8a6d3b;font-weight:500}
.card .footer em{font-style:italic;color:#6a3a1a}
.card:hover{border-color:#8a6d3b}
</style></head><body>
<a class="card" href="${baseUrl}/magazine/${id}" target="_blank" rel="noopener">
  <div class="photo"><span class="cat">${esc(cat)}</span></div>
  <div class="body">
    <h2>${esc(f.headline || f.biz_name)}</h2>
    <div class="subhead">${esc(f.subhead || '')}</div>
    <div class="biz"><strong>${esc(f.biz_name)}</strong>${f.biz_address ? ' · ' + esc(f.biz_address) : ''}${f.city ? ' · ' + esc(f.city) : ''}</div>
  </div>
  <div class="footer">The <em>Corridor</em> · read more →</div>
</a>
</body></html>`);
});

// 9:16 share-card — clean Instagram-story format, screenshot or screencap-ready
app.get('/share/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`,
    [id]
  );
  if (r.rowCount === 0) return res.status(404).send('not found');
  const f = r.rows[0];
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  const cat = f.category_tag || 'shop';
  const catGradient: Record<string,string> = {
    restaurant:   'linear-gradient(135deg,#8a3a1a 0%,#c47b5a 60%,#f4d6b8 100%)',
    professional: 'linear-gradient(135deg,#2d3a4a 0%,#5a6c7d 50%,#8a9ba8 100%)',
    medical:      'linear-gradient(135deg,#2a5a4a 0%,#5a8a7a 50%,#b0d4c4 100%)',
    beauty:       'linear-gradient(135deg,#6a3a5a 0%,#a76a8a 50%,#e8c4d4 100%)',
    salon:        'linear-gradient(135deg,#5a2a4a 0%,#8a5a7a 50%,#d4a4c4 100%)',
    fitness:      'linear-gradient(135deg,#2a3a1a 0%,#5a7a3a 50%,#a4c47a 100%)',
    shop:         'linear-gradient(135deg,#4a2a1a 0%,#8a5a3a 50%,#d4a48a 100%)',
    'real-estate':'linear-gradient(135deg,#1a3a4a 0%,#3a6a8a 50%,#8aa4c4 100%)',
    hospitality:  'linear-gradient(135deg,#4a3a1a 0%,#8a6a3a 50%,#d4b48a 100%)',
    automotive:   'linear-gradient(135deg,#2a2a2a 0%,#5a5a5a 50%,#a4a4a4 100%)',
  };
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.send(`<!doctype html><html><head><meta charset="utf-8">
<title>${esc(f.headline || f.biz_name)} — share card</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500;600&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:#0a0a0c;font-family:'Inter',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.story{
  width:1080px;height:1920px;
  /* Scale to viewport */
  transform-origin:top center;
  background:${catGradient[cat] || catGradient.shop};
  position:relative;color:white;padding:96px 96px 120px;display:flex;flex-direction:column;justify-content:space-between;
  box-shadow:0 30px 80px rgba(0,0,0,0.6);
}
@media(max-width:1100px){.story{transform:scale(0.45);height:864px;width:486px;}}
@media(max-width:600px){.story{transform:scale(0.32);height:614px;width:346px;}}
.story::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse at top,rgba(255,255,255,0.08),transparent 60%),linear-gradient(180deg,transparent 50%,rgba(0,0,0,0.5))}
.story::after{content:'';position:absolute;inset:48px;border:2px solid rgba(255,255,255,0.18);pointer-events:none}
.story > *{position:relative;z-index:2}
.kicker{font-size:24px;letter-spacing:.5em;text-transform:uppercase;font-weight:600;color:rgba(255,255,255,0.9);margin-bottom:20px}
.cat{display:inline-block;background:rgba(0,0,0,0.4);color:white;padding:14px 28px;font-size:22px;letter-spacing:.4em;text-transform:uppercase;font-weight:600;margin-bottom:48px}
.headline{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:140px;line-height:0.92;letter-spacing:-0.02em;margin-bottom:32px;text-shadow:0 4px 20px rgba(0,0,0,0.4)}
.headline em{font-style:normal;color:rgba(255,255,255,0.75)}
.subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:42px;line-height:1.3;color:rgba(255,255,255,0.85);max-width:840px;margin-bottom:auto}
.pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:48px;line-height:1.3;border-left:4px solid white;padding:16px 0 16px 32px;margin-top:48px;max-width:840px}
.footer{display:flex;justify-content:space-between;align-items:flex-end;margin-top:48px}
.footer .biz{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:42px;line-height:1.2}
.footer .biz small{display:block;font-family:'Inter',sans-serif;font-style:normal;font-size:18px;font-weight:400;letter-spacing:.05em;opacity:0.85;margin-top:6px}
.footer .brand{text-align:right}
.footer .brand .mast{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:64px;line-height:1}
.footer .brand .mast em{font-style:normal;opacity:0.7}
.footer .brand .vol{font-size:16px;letter-spacing:.4em;text-transform:uppercase;font-weight:600;margin-top:8px;opacity:0.85}
.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
.toolbar a{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:#888;background:#1a1a1a;padding:6px 12px;border:1px solid #333;text-decoration:none;font-family:'Inter',sans-serif}
.toolbar a:hover{color:white;border-color:white}
@media print{.toolbar{display:none}body{padding:0;background:white}.story{transform:none;box-shadow:none}}
</style>
</head><body>
<div class="toolbar">
  <a href="/magazine/${id}">← Reader</a>
  <a href="javascript:window.print()">🖨 Print</a>
</div>
<div class="story">
  <div>
    <div class="kicker">The Corridor</div>
    <span class="cat">${esc(cat.replace(/-/g,' '))}</span>
    <h1 class="headline">${esc(f.headline || f.biz_name)}</h1>
    <div class="subhead">${esc(f.subhead || '')}</div>
  </div>
  ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
  <div class="footer">
    <div class="biz">${esc(f.biz_name)}<small>${esc(f.biz_address || '')}${f.city ? ' · ' + esc(f.city) : ''}</small></div>
    <div class="brand">
      <div class="mast">The <em>Corridor</em></div>
      <div class="vol">Vol I · 2026</div>
    </div>
  </div>
</div>
</body></html>`);
});

// Full-page reader for one feature
app.get('/magazine/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!Number.isFinite(id)) return res.status(400).send('bad id');
  const r = await query(
    `SELECT mf.*, b.name AS biz_name, b.address AS biz_address, b.city, b.zip,
            b.raw->>'primary_naics_description' AS naics
     FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
     WHERE mf.id = $1`,
    [id]
  );
  if (r.rowCount === 0) return res.status(404).send('feature not found');
  const f = r.rows[0];
  // Bump views
  await query(`UPDATE magazine_features SET views = views + 1 WHERE id = $1`, [id]);
  // Pull 3 related features from same category
  const rel = await query(
    `SELECT mf.id, mf.headline, mf.subhead, b.name AS biz_name
     FROM magazine_features mf
     JOIN businesses b ON b.id = mf.business_id
     WHERE mf.category_tag = $1 AND mf.id <> $2
     ORDER BY random()
     LIMIT 3`,
    [f.category_tag, id]
  );
  // Latest 5 scraped news/blog/press articles from this business's website.
  // Closes the loop between editorial features and live news so readers see
  // both "what we said about them" + "what they're saying right now".
  const newsBiz = await query(
    `SELECT id, source_url, title, excerpt, summary, published_guess, fetched_at
       FROM news_items WHERE business_id = $1
      ORDER BY COALESCE(published_guess, fetched_at::date) DESC,
               fetched_at DESC
      LIMIT 5`,
    [f.business_id]
  );
  // Pull last 10 pipeline events for the underlying business (status changes, walks, replies, etc)
  const events = await query(
    `SELECT pel.event_type, pel.old_value, pel.new_value, pel.occurred_at
     FROM pitch_event_log pel
     JOIN pitches p ON p.id = pel.pitch_id
     WHERE p.business_id = $1
     ORDER BY pel.occurred_at DESC, pel.id DESC
     LIMIT 10`,
    [f.business_id]
  );
  // Pull building-mates: other businesses in the same canonical building
  const bldg = (f.biz_address || '').replace(/\s*(SUITE|STE|UNIT|#).*$/i, '').trim();
  const mates = bldg ? await query(
    `SELECT mf2.id, mf2.headline, b2.name AS biz_name, bc.suite
     FROM v_building_canonical bc
     LEFT JOIN magazine_features mf2 ON mf2.business_id = bc.id
     JOIN businesses b2 ON b2.id = bc.id
     WHERE bc.bldg_address = $1
       AND bc.id <> $2
     ORDER BY mf2.id IS NULL ASC, bc.suite NULLS LAST, b2.name
     LIMIT 8`,
    [bldg, f.business_id]
  ) : { rows: [] as any[] };
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
  const desc = (f.subhead || (f.editorial || '').slice(0, 160) || '').replace(/\s+/g, ' ').trim();
  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>${esc(f.headline || f.biz_name)} — The Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="${esc(desc).slice(0, 200)}">
<meta property="og:type" content="article">
<meta property="og:title" content="${esc(f.headline || f.biz_name)}">
<meta property="og:description" content="${esc(desc).slice(0, 200)}">
<meta property="og:url" content="${baseUrl}/magazine/${id}">
<meta property="og:site_name" content="The Corridor">
<meta property="og:image" content="${baseUrl}/share/${id}">
<meta property="og:image:width" content="1080">
<meta property="og:image:height" content="1920">
<meta property="article:section" content="${esc(f.category_tag || 'feature')}">
<meta property="article:published_time" content="${f.published_at ? new Date(f.published_at).toISOString() : new Date(f.generated_at).toISOString()}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(f.headline || f.biz_name)}">
<meta name="twitter:description" content="${esc(desc).slice(0, 200)}">
<meta name="twitter:image" content="${baseUrl}/share/${id}">
<script type="application/ld+json">${JSON.stringify({
  '@context': 'https://schema.org',
  '@type': 'NewsArticle',
  headline: f.headline || f.biz_name,
  description: desc,
  datePublished: f.published_at ? new Date(f.published_at).toISOString() : new Date(f.generated_at).toISOString(),
  author: { '@type': 'Organization', name: 'The Corridor desk' },
  publisher: { '@type': 'Organization', name: 'The Corridor', logo: { '@type': 'ImageObject', url: `${baseUrl}/favicon.svg` } },
  mainEntityOfPage: `${baseUrl}/magazine/${id}`,
  articleSection: f.category_tag || 'feature',
  about: { '@type': 'LocalBusiness', name: f.biz_name, address: f.biz_address || '' }
})}</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root { --paper:#faf6ee; --ink:#1a1815; --ink-mute:#6e6356; --metal:#8a6d3b; --accent:#6a3a1a; --rule:#d8cdb8; }
html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',system-ui,sans-serif;font-weight:300;}
.toolbar{padding:14px 24px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;font-size:11px;letter-spacing:.18em;text-transform:uppercase}
.toolbar a{color:var(--metal);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
.toolbar a:hover{border-color:var(--metal)}
.toolbar .breadcrumb{color:var(--ink-mute)}
.toolbar .breadcrumb a{border:none;padding:0}
article{max-width:680px;margin:0 auto;padding:60px 24px 80px}
.kicker{font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);font-weight:500}
.cat{display:inline-block;background:var(--ink);color:var(--paper);padding:5px 12px;font-size:9px;letter-spacing:.3em;text-transform:uppercase;font-weight:500;margin-bottom:18px}
h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:64px;line-height:1.0;margin:18px 0 14px;color:var(--ink);letter-spacing:-0.02em}
.subhead{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:22px;line-height:1.4;color:var(--ink-mute);margin:0 0 36px}
.photo{aspect-ratio:16/9;background:linear-gradient(135deg,#c8b894 0%,#8a7754 100%);position:relative;display:flex;align-items:center;justify-content:center;color:var(--paper);font-family:'Cormorant Garamond',serif;font-style:italic;margin-bottom:36px;overflow:hidden;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
.photo::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,0.4))}
.photo .placeholder{font-size:18px;letter-spacing:.05em;text-align:center;padding:24px}
.lede{font-family:'Cormorant Garamond',serif;font-weight:400;font-size:20px;line-height:1.6;color:var(--ink);margin:0 0 24px}
.lede::first-letter{font-family:'Cormorant Garamond',serif;font-weight:600;font-size:84px;line-height:0.85;float:left;margin:8px 12px 0 0;color:var(--accent)}
.pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:28px;line-height:1.3;color:var(--accent);border-left:3px solid var(--metal);padding:14px 0 14px 22px;margin:32px 0}
.meta{margin-top:48px;padding-top:24px;border-top:1px solid var(--rule);display:grid;grid-template-columns:auto 1fr;gap:12px 20px;font-size:13px;color:var(--ink-mute)}
.meta dt{color:var(--ink-mute);font-size:9px;letter-spacing:.26em;text-transform:uppercase;align-self:center}
.meta dd{margin:0;font-family:'Cormorant Garamond',serif;font-size:18px;color:var(--ink)}
.meta dd.mono{font-family:'JetBrains Mono',monospace;font-size:12px}
footer{margin-top:48px;padding:24px 0;text-align:center;font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute)}
@media print{.toolbar{display:none}article{max-width:100%;padding:24px}h1{font-size:42px}.lede::first-letter{font-size:64px}}
</style></head><body>
<div class="toolbar">
  <div class="breadcrumb"><a href="/magazine.html">← The Corridor</a> · ${esc(f.category_tag || 'feature')}</div>
  <div>
    <a href="/api/magazine/${id}/tap?to=share&dest=${encodeURIComponent(`${baseUrl}/share/${id}`)}" target="_blank">📱 IG share-card</a>
    <a href="/api/magazine/${id}/audio.aiff" target="_blank" title="Listen — Samantha reads this feature">🔊 Listen</a>
    <a href="/magazine/${id}/ad-pack.html" target="_blank" title="Copy-ready ad briefs">📋 Ad pack</a>
    <a href="/api/magazine/${id}/tap?to=sponsor&dest=${encodeURIComponent(`${baseUrl}/sponsor/${id}`)}" style="color:var(--accent,#6a3a1a);border-color:var(--accent,#6a3a1a)">⭐ Sponsor</a>
    <a href="javascript:window.print()">🖨 Print</a>
    <a href="/pitches.html?id=${f.business_id}" target="_blank">Pipeline ↗</a>
  </div>
</div>
<article>
  <div class="kicker">${esc(f.category_tag || 'corridor feature')}</div>
  <h1>${esc(f.headline || '(untitled)')}</h1>
  <p class="subhead">${esc(f.subhead || '')}</p>
  <div class="byline" style="font-family:'Inter',sans-serif;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin:0 0 32px;padding-bottom:14px;border-bottom:1px solid var(--rule)">
    By <span style="color:var(--metal)">The Corridor desk</span> · drafted by <code style="background:rgba(184,153,104,0.1);padding:2px 6px;font-family:'JetBrains Mono',monospace;font-size:10px">${esc(f.model || 'qwen3:14b')}</code> on Mac Studio 1
    ${f.reviewed_at ? ` · <span style="color:var(--metal)">reviewed</span> ${new Date(f.reviewed_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}` : ''}
    ${f.published_at ? ` · <span style="color:var(--accent)">★ published</span> ${new Date(f.published_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}` : ''}
  </div>
  <div class="photo"><div class="placeholder">${esc(f.biz_name)}<br><span style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;font-family:Inter">photo TBD</span></div></div>
  <p class="lede">${esc(f.editorial || '')}</p>
  ${f.pull_quote ? `<div class="pull">"${esc(f.pull_quote)}"</div>` : ''}
  <dl class="meta">
    <dt>Business</dt><dd>${esc(f.biz_name)}</dd>
    <dt>Address</dt><dd class="mono">${esc(f.biz_address || '')}</dd>
    <dt>City</dt><dd class="mono">${esc(f.city || '')} ${esc(f.zip || '')}</dd>
    ${f.naics ? `<dt>Trade</dt><dd class="mono">${esc(f.naics)}</dd>` : ''}
    <dt>Status</dt><dd class="mono">${esc(f.status)}</dd>
    <dt>Generated</dt><dd class="mono">${new Date(f.generated_at).toLocaleString('en-US', { month:'short', day:'numeric', year:'numeric'})} via ${esc(f.model)}</dd>
    <dt>Views</dt><dd class="mono">${f.views || 0}</dd>
  </dl>
  ${events.rows.length > 0 ? `<details style="margin-top:48px;padding-top:24px;border-top:1px solid var(--rule)">
    <summary style="cursor:pointer;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);font-family:'Inter',sans-serif">Editorial dossier · ${events.rowCount} pipeline events</summary>
    <div style="margin-top:14px;font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.7;color:var(--ink-mute)">
      ${events.rows.map((ev: any) => {
        const arrow = ev.old_value ? `${esc(ev.old_value)} → ${esc(ev.new_value || '')}` : esc(ev.new_value || '');
        const ts = new Date(ev.occurred_at).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
        return `<div><span style="color:var(--metal)">${ts}</span> · <span style="color:var(--ink)">${esc(ev.event_type)}</span>${arrow ? ' · ' + arrow : ''}</div>`;
      }).join('')}
    </div>
    <div style="margin-top:8px;font-size:10px;color:var(--ink-mute);font-style:italic">From the DW outreach pipeline that produces the magazine — every walk, every reply, every status change.</div>
  </details>` : ''}
  ${newsBiz.rows.length > 0 ? `<section style="margin-top:48px;padding-top:24px;border-top:1px solid var(--rule)">
    <div style="display:flex;justify-content:space-between;align-items:baseline;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:14px">
      <span>📰 Latest from their site · ${newsBiz.rowCount}</span>
      <a href="/business/${f.business_id}/news" style="color:var(--metal);text-decoration:none">All news from ${esc(f.biz_name)} ↗</a>
    </div>
    <div style="display:flex;flex-direction:column;gap:0">
      ${newsBiz.rows.map((n: any) => {
        const dt = n.published_guess || n.fetched_at;
        const dateStr = dt ? new Date(dt).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '';
        const text = n.summary || n.excerpt || '';
        let host = '';
        try { host = new URL(n.source_url).hostname.replace(/^www\./, ''); } catch {}
        const safeHref = (() => {
          try {
            const u = new URL(String(n.source_url || ''));
            return (u.protocol === 'http:' || u.protocol === 'https:') ? esc(u.toString()) : '#';
          } catch { return '#'; }
        })();
        return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer" style="display:block;padding:12px 0;border-bottom:1px dotted var(--rule);color:var(--ink);text-decoration:none">
          <h4 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:18px;line-height:1.2;margin:0 0 4px">${esc((n.title || '(untitled)').slice(0, 140))}</h4>
          <p style="font-size:13px;line-height:1.5;color:var(--ink-mute);margin:0 0 6px">${esc(text.slice(0, 220))}${text.length > 220 ? '…' : ''}</p>
          <div style="font-family:'Inter',sans-serif;font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:10px">
            <span>${esc(dateStr)}</span><span>·</span><span style="color:var(--metal)">${esc(host)}</span>
          </div>
        </a>`;
      }).join('')}
    </div>
  </section>` : ''}
  ${mates.rows.length > 0 ? `<section style="margin-top:48px;padding-top:24px;border-top:1px solid var(--rule)">
    <div style="display:flex;justify-content:space-between;align-items:baseline;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:14px">
      <span>In the same building · ${esc(bldg)}</span>
      <a href="/buildings/${encodeURIComponent(bldg)}/issue" style="color:var(--metal);text-decoration:none">Building issue ↗</a>
    </div>
    <div style="display:flex;flex-direction:column;gap:6px">
      ${mates.rows.map((m: any) => m.id ? `<a href="/magazine/${m.id}" style="color:var(--ink);text-decoration:none;display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1px dotted var(--rule)">
        <span style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:16px">${esc(m.headline || m.biz_name)}</span>
        <span style="font-family:'Inter',sans-serif;font-size:10px;letter-spacing:.18em;color:var(--ink-mute);text-transform:uppercase">${m.suite ? '#' + esc(m.suite) : esc(m.biz_name)}</span>
      </a>` : `<div style="color:var(--ink-mute);font-style:italic;font-size:13px;padding:6px 0;border-bottom:1px dotted var(--rule);display:flex;justify-content:space-between">
        <span>${esc(m.biz_name)}</span>
        <span style="font-family:'Inter',sans-serif;font-size:10px;letter-spacing:.18em">${m.suite ? '#' + esc(m.suite) : ''} · not yet featured</span>
      </div>`).join('')}
    </div>
  </section>` : ''}
  ${rel.rows.length > 0 ? `<section style="margin-top:48px;padding-top:24px;border-top:1px solid var(--rule)">
    <div style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:16px">More in ${esc(f.category_tag || 'this category')}</div>
    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:18px">
      ${rel.rows.map((r: any) => `<a href="/magazine/${r.id}" style="display:block;padding:14px;border:1px solid var(--rule);text-decoration:none;color:var(--ink)">
        <h4 style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:18px;margin:0 0 6px;line-height:1.2">${esc(r.headline || r.biz_name)}</h4>
        <div style="font-family:'Inter',sans-serif;font-size:11px;color:var(--ink-mute);letter-spacing:.05em">${esc(r.biz_name)}</div>
      </a>`).join('')}
    </div>
  </section>` : ''}
  <details style="margin-top:48px;padding-top:24px;border-top:1px solid var(--rule)">
    <summary style="cursor:pointer;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);font-family:'Inter',sans-serif">Notice a typo, fact slip, or want to add something?</summary>
    <form id="fb-form-${id}" style="margin-top:16px;display:flex;flex-direction:column;gap:10px" onsubmit="return submitFb(event, ${id})">
      <select name="kind" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:8px 10px;font-family:'Inter',sans-serif;font-size:12px">
        <option value="typo">Typo or copy edit</option>
        <option value="correction">Factual correction</option>
        <option value="tip">Story tip / what we missed</option>
        <option value="praise">Just kind words</option>
        <option value="complaint">Concern or take-down request</option>
        <option value="note">Other</option>
      </select>
      <textarea name="body" required minlength="4" maxlength="2000" placeholder="What did you notice?" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:10px 12px;font-family:'Cormorant Garamond',serif;font-size:14px;min-height:80px;resize:vertical;line-height:1.5"></textarea>
      <input name="contact" type="text" placeholder="Email (optional, only if you want a reply)" style="background:transparent;border:1px solid var(--rule);color:var(--ink);padding:8px 12px;font-family:'Inter',sans-serif;font-size:12px">
      <button type="submit" style="background:var(--ink);color:var(--paper);border:none;padding:10px 24px;font-size:10px;letter-spacing:.32em;text-transform:uppercase;font-weight:500;cursor:pointer;font-family:'Inter',sans-serif;align-self:flex-start">Send to the desk</button>
    </form>
    <div id="fb-thanks-${id}" style="display:none;color:var(--green,#6a9b73);font-style:italic;margin-top:12px;font-family:'Cormorant Garamond',serif;font-size:16px">Got it. Thanks for the note.</div>
  </details>
  <script>
    async function submitFb(e, fid) {
      e.preventDefault();
      const form = document.getElementById('fb-form-' + fid);
      const data = Object.fromEntries(new FormData(form));
      data.feature_id = fid;
      data.source_path = location.pathname;
      const r = await fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
      if (r.ok) {
        form.style.display = 'none';
        document.getElementById('fb-thanks-' + fid).style.display = 'block';
      } else {
        alert('Sorry — something failed. Try again?');
      }
      return false;
    }
  </script>
  <footer>The Corridor · Volume I · 2026 · all loopback / not for redistribution</footer>
</article>
</body></html>`);
});

app.patch('/api/magazine/:id', express.json(), async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    const allowed = ['headline','subhead','editorial','pull_quote','category_tag','photo_url','status','notes'];
    const sets: string[] = [];
    const params: any[] = [];
    for (const k of allowed) if (k in (req.body || {})) {
      params.push(req.body[k] === '' ? null : req.body[k]);
      sets.push(`${k} = $${params.length}`);
    }
    if (req.body?.status === 'reviewed') sets.push('reviewed_at = NOW()');
    if (req.body?.status === 'published') sets.push('published_at = COALESCE(published_at, NOW())');
    if (!sets.length) return res.status(400).json({ error: 'no fields' });
    params.push(id);
    await query(`UPDATE magazine_features SET ${sets.join(', ')} WHERE id = $${params.length}`, params);
    res.json({ ok: true });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Stale pitches: sent >N days ago, no reply, not closed ─────────────
// Powers the "Cold leads" card on /today.html.
app.get('/api/stale-pitches', async (req, res) => {
  try {
    const days = Math.max(parseInt(String(req.query.days ?? '14'), 10) || 14, 1);
    const limit = Math.min(parseInt(String(req.query.limit ?? '15'), 10) || 15, 100);
    const r = await query(
      `SELECT p.id, p.pitch_type, p.priority, p.status, p.outreach_channel,
              p.sent_at, p.next_followup_at, p.contact_name, p.email, p.phone, p.linkedin,
              b.name, b.address, b.city, b.zip,
              FLOOR(EXTRACT(EPOCH FROM (NOW() - p.sent_at))/86400)::int AS days_silent
       FROM pitches p
       JOIN businesses b ON b.id = p.business_id
       WHERE p.sent_at IS NOT NULL
         AND p.sent_at < NOW() - ($1 || ' days')::interval
         AND p.replied_at IS NULL
         AND p.closed_at IS NULL
         AND p.status NOT IN ('skip','won','lost')
       ORDER BY p.sent_at ASC
       LIMIT $2`,
      [days, limit]
    );
    res.json({ count: r.rowCount, days_threshold: days, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Per-vertical conversion: which pitch_type replies/wins most ──────
app.get('/api/responses/by-vertical', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        pitch_type,
        count(*)                                            AS pitches,
        count(*) FILTER (WHERE sent_at IS NOT NULL)         AS sent,
        count(*) FILTER (WHERE replied_at IS NOT NULL)      AS replied,
        count(*) FILTER (WHERE status = 'won')              AS won,
        count(*) FILTER (WHERE status = 'lost')             AS lost,
        ROUND(100.0 * count(*) FILTER (WHERE replied_at IS NOT NULL)
              / NULLIF(count(*) FILTER (WHERE sent_at IS NOT NULL), 0), 1)        AS reply_rate_pct,
        ROUND(100.0 * count(*) FILTER (WHERE status = 'won')
              / NULLIF(count(*) FILTER (WHERE sent_at IS NOT NULL), 0), 1)        AS win_rate_pct,
        COALESCE(SUM(won_value_usd) FILTER (WHERE status = 'won'), 0)             AS won_value_usd,
        ROUND(AVG(won_value_usd) FILTER (WHERE status = 'won')::numeric, 2)       AS avg_won_value
      FROM pitches
      WHERE pitch_type IS NOT NULL
      GROUP BY pitch_type
      ORDER BY count(*) FILTER (WHERE sent_at IS NOT NULL) DESC, pitches DESC
    `);
    res.json({ count: r.rowCount, by_vertical: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Cycle-time analytics: how long sent → reply / sent → close ─────
app.get('/api/responses/cycle-time', async (_req, res) => {
  try {
    const r = await query(`
      SELECT
        outreach_channel AS channel,
        count(*)                                            AS pitches,
        ROUND(AVG(hours_to_reply)::numeric, 1)              AS avg_hours_to_reply,
        ROUND(percentile_cont(0.5) WITHIN GROUP (ORDER BY hours_to_reply)::numeric, 1) AS median_hours_to_reply,
        ROUND(AVG(days_to_close)::numeric,  1)              AS avg_days_to_close,
        ROUND(percentile_cont(0.5) WITHIN GROUP (ORDER BY days_to_close)::numeric, 1)  AS median_days_to_close
      FROM v_pitch_cycle_time
      GROUP BY outreach_channel
      ORDER BY pitches DESC
    `);
    res.json({ by_channel: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Daily-snapshot trend feed ──────────────────────────────────────
// Powers the trend chart on /today.html. Returns last N days of pitch state.
app.get('/api/snapshots', async (req, res) => {
  try {
    const days = Math.min(Math.max(parseInt(String(req.query.days ?? '14'), 10) || 14, 1), 90);
    const dim = String(req.query.dim || 'totals');
    if (!['status', 'outreach_channel', 'dw_proximity', 'totals'].includes(dim)) {
      return res.status(400).json({ error: 'dim must be status|outreach_channel|dw_proximity|totals' });
    }
    const r = await query(
      `
      SELECT snap_date, bucket, count
      FROM pitch_daily_snapshot
      WHERE dim = $1 AND snap_date >= CURRENT_DATE - $2::int
      ORDER BY snap_date ASC, bucket ASC
      `,
      [dim, days]
    );
    // Pivot into { dates: [...], series: { bucket: [counts...] } }
    const dates = Array.from(new Set(r.rows.map((x: any) => x.snap_date.toISOString().slice(0, 10)))).sort();
    const buckets = Array.from(new Set(r.rows.map((x: any) => x.bucket)));
    const series: Record<string, number[]> = {};
    for (const b of buckets) series[b] = dates.map(() => 0);
    for (const row of r.rows) {
      const d = row.snap_date.toISOString().slice(0, 10);
      const i = dates.indexOf(d);
      series[row.bucket][i] = row.count;
    }
    res.json({ dim, days, dates, series });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Responses: who replied, who won, who lost, scheduled follow-ups ─
// Powers /responses.html.
app.get('/api/responses/funnel', async (_req, res) => {
  try {
    const r = await query(`SELECT * FROM v_response_funnel`);
    const totalSent    = r.rows.reduce((a, x) => a + Number(x.sent || 0), 0);
    const totalReplied = r.rows.reduce((a, x) => a + Number(x.replied || 0), 0);
    const totalWon     = r.rows.reduce((a, x) => a + Number(x.won || 0), 0);
    const totalLost    = r.rows.reduce((a, x) => a + Number(x.lost || 0), 0);
    const totalValue   = r.rows.reduce((a, x) => a + Number(x.won_value_usd || 0), 0);
    res.json({
      by_channel: r.rows,
      totals: {
        sent: totalSent, replied: totalReplied, won: totalWon, lost: totalLost,
        won_value_usd: totalValue,
        reply_rate_pct: totalSent ? Math.round(1000 * totalReplied / totalSent) / 10 : null
      }
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/responses', async (req, res) => {
  try {
    const filter = String(req.query.filter || 'all'); // all | replied | won | lost
    const where: string[] = ['p.replied_at IS NOT NULL OR p.status IN (\'won\',\'lost\')'];
    if (filter === 'replied') where.push("p.status = 'replied' OR (p.replied_at IS NOT NULL AND p.status NOT IN ('won','lost'))");
    if (filter === 'won')     where.push("p.status = 'won'");
    if (filter === 'lost')    where.push("p.status = 'lost'");
    const sql = `
      SELECT p.id, p.business_id, b.name, b.address, b.city, p.pitch_type, p.status,
             p.outreach_channel, p.reply_channel, p.reply_text,
             p.sent_at, p.replied_at, p.closed_at, p.won_value_usd, p.lost_reason,
             p.contact_name, p.email, p.phone, p.linkedin, p.next_followup_at,
             p.followup_notes, p.notes, p.dw_proximity
      FROM pitches p
      JOIN businesses b ON b.id = p.business_id
      WHERE ${where.join(' AND ')}
      ORDER BY COALESCE(p.closed_at, p.replied_at) DESC NULLS LAST
      LIMIT 300
    `;
    const r = await query(sql);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/followups', async (_req, res) => {
  try {
    const r = await query(`SELECT * FROM v_followups_due LIMIT 200`);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Ghosts — businesses whose website 404s, DNS-fails, or won't load ─
// Powers /eulogy.html, the memorial wall.
let _ghostCache: any = null;
let _ghostCacheAt = 0;
app.get('/api/ghosts', async (_req, res) => {
  try {
    if (Date.now() - _ghostCacheAt < 60_000 && _ghostCache) return res.json(_ghostCache);
    const r = await query(`
      SELECT b.id, b.name, b.address, b.city, b.zip, b.website, b.category,
        CASE
          WHEN a.error_message ~* 'NAME_NOT_RESOLVED' THEN 'dns_dead'
          WHEN a.error_message ~* 'CONNECTION_REFUSED|TIMEOUT|HTTP2_PROTOCOL_ERROR' THEN 'unreachable'
          WHEN e.crawl_blocked = 'cloudflare' THEN 'challenge_wall'
          WHEN e.headline IS NULL THEN 'silent'
          ELSE 'unknown_error'
        END AS death_cause,
        a.error_message,
        a.audited_at AS last_attempt,
        e.headline_voice_label AS last_voice
      FROM businesses b
      LEFT JOIN business_enrichment e ON e.business_id = b.id
      LEFT JOIN front_page_audits a ON a.business_id = b.id
      WHERE b.on_corridor AND b.website IS NOT NULL
        AND (a.error_message IS NOT NULL
             OR e.crawl_blocked IS NOT NULL
             OR (e.headline IS NULL AND a.business_id IS NOT NULL))
      ORDER BY b.name
    `);
    _ghostCache = { count: r.rowCount, rows: r.rows };
    _ghostCacheAt = Date.now();
    res.json(_ghostCache);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Random quotes — for the landing-page voices tease ───────────────
app.get('/api/quotes', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit ?? '3'), 10) || 3, 12);
    const minVoice = Math.max(1, Math.min(parseInt(String(req.query.min_voice ?? '3'), 10) || 3, 4));
    const r = await query(`
      SELECT b.id, b.name, b.city, b.website,
             e.headline, e.headline_voice, e.headline_voice_label
      FROM business_enrichment e
      JOIN businesses b ON b.id = e.business_id
      WHERE b.on_corridor
        AND e.headline_voice >= $1
        AND e.headline IS NOT NULL
        AND e.crawl_blocked IS NULL
      ORDER BY random()
      LIMIT $2
    `, [minVoice, limit]);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Atlas — single aggregate stats endpoint for an overview surface ─
let _atlasCache: any = null;
let _atlasCacheAt = 0;
app.get('/api/atlas', async (_req, res) => {
  try {
    if (Date.now() - _atlasCacheAt < 60_000 && _atlasCache) return res.json(_atlasCache);
    const [stats, voiceMix, topCats, paidMix, freshness] = await Promise.all([
      query(`
        SELECT
          (SELECT count(*) FROM businesses WHERE on_corridor) AS total_businesses,
          (SELECT count(*) FROM businesses WHERE on_corridor AND website IS NOT NULL) AS with_website,
          (SELECT count(*) FROM business_enrichment WHERE ad_signals IS NOT NULL) AS crawled,
          (SELECT count(*) FROM business_enrichment WHERE (ad_signals->>'paid_ads_count')::int > 0) AS paid_advertisers,
          (SELECT count(*) FROM business_enrichment WHERE (ad_signals->'transparency'->>'ad_count')::int > 0) AS confirmed_live,
          (SELECT sum(NULLIF((ad_signals->'transparency'->>'ad_count'),'')::int) FROM business_enrichment) AS total_live_ads,
          (SELECT count(*) FROM business_enrichment WHERE chamber_memberships IS NOT NULL) AS chamber_tagged,
          (SELECT count(*) FROM business_enrichment WHERE headline IS NOT NULL) AS with_headline,
          (SELECT count(*) FROM business_enrichment WHERE crawl_blocked IS NOT NULL) AS crawl_blocked
      `),
      query(`SELECT headline_voice_label AS label, count(*)::int AS n FROM business_enrichment WHERE headline_voice IS NOT NULL GROUP BY 1 ORDER BY 2 DESC`),
      query(`SELECT category, count(*)::int AS n FROM businesses WHERE on_corridor AND category IS NOT NULL GROUP BY 1 ORDER BY 2 DESC LIMIT 15`),
      query(`
        SELECT
          count(*) FILTER (WHERE (ad_signals->>'google_ads')::boolean)        AS google_ads,
          count(*) FILTER (WHERE (ad_signals->>'meta_pixel')::boolean)        AS meta_pixel,
          count(*) FILTER (WHERE (ad_signals->>'tiktok_pixel')::boolean)      AS tiktok_pixel,
          count(*) FILTER (WHERE (ad_signals->>'pinterest_tag')::boolean)     AS pinterest_tag,
          count(*) FILTER (WHERE (ad_signals->>'linkedin_insight')::boolean)  AS linkedin_insight,
          count(*) FILTER (WHERE (ad_signals->>'bing_uet')::boolean)          AS bing_uet,
          count(*) FILTER (WHERE (ad_signals->>'snap_pixel')::boolean)        AS snap_pixel
        FROM business_enrichment
      `),
      query(`
        SELECT
          (SELECT max(audited_at)              FROM front_page_audits)         AS last_crawl,
          (SELECT max(signals_updated_at)      FROM business_enrichment)       AS last_signals_run,
          (SELECT max(headline_extracted_at)   FROM business_enrichment)       AS last_headline_extract,
          (SELECT max(headline_voice_at)       FROM business_enrichment)       AS last_voice_classify,
          (SELECT max(crawl_blocked_at)        FROM business_enrichment)       AS last_block_flag
      `),
    ]);
    _atlasCache = {
      stats: stats.rows[0],
      voice_mix: voiceMix.rows,
      top_categories: topCats.rows,
      paid_pixel_mix: paidMix.rows[0],
      freshness: freshness.rows[0],
      generated_at: new Date().toISOString(),
    };
    _atlasCacheAt = Date.now();
    res.json(_atlasCache);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Chains — businesses with the same name appearing >1 time ────────
let _chainCache: any = null;
let _chainCacheAt = 0;
app.get('/api/chains', async (_req, res) => {
  try {
    if (Date.now() - _chainCacheAt < 5 * 60_000 && _chainCache) return res.json(_chainCache);
    const r = await query(`
      WITH normed AS (
        SELECT id, name, category, upper(trim(city)) AS city,
               regexp_replace(upper(trim(name)), '[^A-Z0-9 ]', '', 'g') AS nname
        FROM businesses
        WHERE on_corridor AND length(trim(name)) >= 4
          AND regexp_replace(upper(trim(name)), '[^A-Z0-9 ]', '', 'g') NOT IN ('NA', 'NONE', 'TBD', 'TEST')
      ),
      grouped AS (
        SELECT nname,
               count(*) AS n_locations,
               array_agg(DISTINCT name ORDER BY name) AS variants,
               array_agg(DISTINCT city ORDER BY city) AS cities,
               (array_agg(category) FILTER (WHERE category IS NOT NULL))[1] AS sample_category,
               array_agg(id) AS ids
        FROM normed
        GROUP BY nname
        HAVING count(*) >= 2
      ),
      with_news AS (
        SELECT g.*,
               COALESCE(
                 (SELECT COUNT(*)::int FROM news_items WHERE business_id = ANY(g.ids)),
                 0
               ) AS news_count
        FROM grouped g
      )
      SELECT nname AS name, n_locations, variants, cities, sample_category, news_count
      FROM with_news
      ORDER BY n_locations DESC, nname ASC
      LIMIT 60
    `);
    _chainCache = { count: r.rowCount, rows: r.rows };
    _chainCacheAt = Date.now();
    res.json(_chainCache);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Dictionary — n-gram of headline vocabulary ──────────────────────
// What words & phrases are most common across the corridor's H1s?
const STOPWORDS = new Set('a an and the of to in on at for by with from as is are was were be been being do does did has have had we us our you your he she it they them this that these those or but if then so not no yes also more most some all any each every'.split(' '));
let _dictCache: any = null;
let _dictCacheAt = 0;
app.get('/api/dictionary', async (_req, res) => {
  try {
    if (Date.now() - _dictCacheAt < 5 * 60_000 && _dictCache) return res.json(_dictCache);
    const r = await query<{ headline: string }>(`SELECT headline FROM business_enrichment WHERE headline IS NOT NULL`);
    const uni = new Map<string, number>();
    const bi = new Map<string, number>();
    const tri = new Map<string, number>();
    for (const row of r.rows) {
      const tokens = (row.headline || '').toLowerCase().replace(/[^\w\s']/g, ' ').split(/\s+/).filter(t => t && t.length >= 2);
      for (let i = 0; i < tokens.length; i++) {
        const w = tokens[i];
        if (!STOPWORDS.has(w)) uni.set(w, (uni.get(w) || 0) + 1);
        if (i + 1 < tokens.length) {
          const b = tokens[i] + ' ' + tokens[i + 1];
          if (!STOPWORDS.has(tokens[i]) || !STOPWORDS.has(tokens[i + 1])) bi.set(b, (bi.get(b) || 0) + 1);
        }
        if (i + 2 < tokens.length) {
          const t3 = tokens[i] + ' ' + tokens[i + 1] + ' ' + tokens[i + 2];
          tri.set(t3, (tri.get(t3) || 0) + 1);
        }
      }
    }
    const top = (m: Map<string, number>, n: number, minCount: number) =>
      Array.from(m.entries()).filter(([, c]) => c >= minCount).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, c]) => ({ phrase: k, count: c }));
    _dictCache = {
      total_headlines: r.rowCount,
      unigrams: top(uni, 40, 3),
      bigrams: top(bi, 40, 2),
      trigrams: top(tri, 40, 2),
    };
    _dictCacheAt = Date.now();
    res.json(_dictCache);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Tongues — every H1 rendered at its geographic longitude ─────────
// The boulevard speaks; geography IS the sentence.
app.get('/api/tongues', async (_req, res) => {
  try {
    const r = await query(`
      SELECT b.id, b.name, b.city, b.lat, b.lng, b.website,
             e.headline, e.headline_voice,
             (e.ad_signals->>'paid_ads_count')::int AS paid_count,
             COALESCE((e.ad_signals->'transparency'->>'ad_count')::int, 0) AS live_ads
      FROM businesses b
      JOIN business_enrichment e ON e.business_id = b.id
      WHERE b.on_corridor
        AND b.lat IS NOT NULL AND b.lng IS NOT NULL
        AND e.headline IS NOT NULL AND length(e.headline) >= 4
      ORDER BY b.lng ASC
    `);
    res.json({
      count: r.rowCount,
      west_lng: r.rows[0]?.lng ?? null,
      east_lng: r.rows[r.rowCount - 1]?.lng ?? null,
      rows: r.rows,
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── News feed (scraped per-business news/blog/press posts) ──────────
// Reads news_items populated by src/crawl/news_scraper.ts. Magazine surface;
// gated like the rest of the corridor admin tools.
app.get('/api/news/recent', async (req, res) => {
  try {
    const limit = Math.min(parseInt(String(req.query.limit || '40'), 10) || 40, 200);
    const r = await query(`
      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
             n.published_guess, n.fetched_at,
             b.name AS business_name, b.category, b.city, b.website
        FROM news_items n
        JOIN businesses b ON b.id = n.business_id
       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
                n.fetched_at DESC
       LIMIT $1
    `, [limit]);
    res.set('Cache-Control', 'public, max-age=120');
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Weekly archive — surfaces the full historical span of scraped news_items,
// bucketed by ISO week. Uses published_guess when available (so a 2014 post
// stays in 2014's bin), falls back to fetched_at::date otherwise.
app.get('/api/news/archive', async (_req, res) => {
  try {
    const r = await query(`
      WITH bucketed AS (
        SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
               n.published_guess, n.fetched_at,
               b.name AS business_name, b.city,
               date_trunc('week', COALESCE(n.published_guess, n.fetched_at::date))::date AS week
          FROM news_items n
          JOIN businesses b ON b.id = n.business_id
         WHERE COALESCE(n.published_guess, n.fetched_at::date) IS NOT NULL
      )
      SELECT week,
             COUNT(*)::int AS n,
             json_agg(json_build_object(
               'id', id, 'business_id', business_id, 'business_name', business_name,
               'city', city, 'title', title, 'source_url', source_url,
               'summary', summary, 'excerpt', excerpt,
               'date', COALESCE(published_guess, fetched_at::date)
             ) ORDER BY COALESCE(published_guess, fetched_at::date) DESC) AS items
        FROM bucketed
       GROUP BY week
       ORDER BY week DESC
       LIMIT 60
    `);
    res.set('Cache-Control', 'public, max-age=600');
    res.json({ count: r.rowCount, weeks: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Bridge to the idea-loop skill at ~/.claude/skills/idea-loop/data/ideas.jsonl.
// Reads the JSONL on demand (not large, ~kb scale), returns last N parsed
// entries. Pure read; idea-loop owns its own writes.
// Idea-loop stats: accept/reject totals + breakdown by type. Useful to spot
// "is the panel too generous" (>50% accept) or "too harsh" (<5% accept).
// Session-at-a-glance — single endpoint that aggregates "what was tonight"
// for the coverage page footer. Reads git log for commit count + LoC,
// news_items for content state, idea-loop jsonl for creative state.
app.get('/api/session-stats', async (_req, res) => {
  try {
    const fs = await import('node:fs/promises');
    const path = await import('node:path');
    const { exec } = await import('node:child_process');
    const execP = (cmd: string) => new Promise<string>((resolve) => {
      exec(cmd, { cwd: process.cwd(), encoding: 'utf8', timeout: 4000 }, (_e, out) => resolve(out || ''));
    });

    // Tonight = since the news pipeline started (7594bb3, ~6 hours ago)
    const SESSION_BASE = '7594bb3';
    const commitCount = parseInt((await execP(`git rev-list --count ${SESSION_BASE}..HEAD 2>/dev/null`)).trim(), 10) || 0;
    const shortStat = (await execP(`git diff ${SESSION_BASE}..HEAD --shortstat 2>/dev/null`)).trim();
    const insMatch = shortStat.match(/(\d+) insertion/);
    const insertions = insMatch ? parseInt(insMatch[1], 10) : 0;

    // News + biz counts
    const newsR = await query(`
      SELECT
        COUNT(*)::int AS news_total,
        COUNT(DISTINCT business_id)::int AS news_biz,
        COUNT(*) FILTER (WHERE summary IS NOT NULL)::int AS summarized
      FROM news_items
    `);

    // Ideas
    const home = process.env.HOME || require('os').homedir() || '';
    let ideasAccepted = 0, ideasRejected = 0;
    try {
      const a = await fs.readFile(path.join(home, '.claude', 'skills', 'idea-loop', 'data', 'ideas.jsonl'), 'utf8');
      ideasAccepted = a.split(/\r?\n/).filter(Boolean).length;
    } catch {}
    try {
      const r = await fs.readFile(path.join(home, '.claude', 'skills', 'idea-loop', 'data', 'rejects.jsonl'), 'utf8');
      ideasRejected = r.split(/\r?\n/).filter(Boolean).length;
    } catch {}

    res.set('Cache-Control', 'public, max-age=60');
    res.json({
      commits: commitCount,
      insertions,
      news_total: newsR.rows[0]?.news_total || 0,
      news_biz: newsR.rows[0]?.news_biz || 0,
      summarized: newsR.rows[0]?.summarized || 0,
      ideas_accepted: ideasAccepted,
      ideas_rejected: ideasRejected,
      session_base: SESSION_BASE
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/ideas/stats', async (_req, res) => {
  try {
    const fs = await import('node:fs/promises');
    const path = await import('node:path');
    const home = process.env.HOME || require('os').homedir() || '';
    if (!home) return res.json({ count: 0, rows: [], note: 'home dir unresolvable' });
    const dir = path.join(home, '.claude', 'skills', 'idea-loop', 'data');
    const readJsonl = async (file: string) => {
      try {
        const txt = await fs.readFile(path.join(dir, file), 'utf8');
        return txt.split(/\r?\n/).filter(Boolean).map(l => {
          try { return JSON.parse(l); } catch { return null; }
        }).filter(Boolean);
      } catch { return []; }
    };
    const accepted = await readJsonl('ideas.jsonl');
    const rejected = await readJsonl('rejects.jsonl');
    const by = (arr: any[]) => arr.reduce((m: Record<string, number>, x) => {
      const t = String(x.type || 'other');
      m[t] = (m[t] || 0) + 1; return m;
    }, {});
    const total = accepted.length + rejected.length;
    res.set('Cache-Control', 'public, max-age=60');
    res.json({
      accepted: accepted.length,
      rejected: rejected.length,
      accept_rate: total ? +(accepted.length / total).toFixed(3) : 0,
      accepted_by_type: by(accepted),
      rejected_by_type: by(rejected)
    });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Fleet status — proxy both Mac dashboards so corridor can show "what
// is Mac1/Mac2 doing right now" inline. Soft-fails per machine, so a
// dead dashboard doesn't 500 the whole page.
app.get('/api/fleet', async (_req, res) => {
  const fetchOne = async (url: string, label: string) => {
    try {
      const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
      if (!r.ok) return { ok: false, label, status: r.status };
      const j: any = await r.json();
      return {
        ok: true, label,
        cpu: j.cpu?.total ?? 0,
        ram_pct: j.memory?.used_pct ?? 0,
        ollama_models: (j.ollama?.models || []).map((m: any) => ({
          name: m.name, vram_gb: +(((m.size_vram_bytes || 0) / (1024 ** 3)).toFixed(2)),
          pinned: m.expires_at ? (new Date(m.expires_at).getTime() - Date.now()) > (180 * 86400 * 1000) : false
        })),
        installed_count: j.ollama?.installed_count || 0,
        ollama_proc: (j.ollama_procs || [])[0] || null,
        power_mode: j.power_mode || 'auto',
        url: url.replace('/api/status', '')
      };
    } catch (e: any) {
      return { ok: false, label, error: String(e?.message || e) };
    }
  };
  const [mac1, mac2] = await Promise.all([
    fetchOne('http://100.94.103.98:9930/api/status', 'Mac Studio 1'),
    fetchOne('http://localhost:9931/api/status',     'Mac Studio 2')
  ]);
  res.set('Cache-Control', 'public, max-age=10');
  res.json({ macs: [mac1, mac2], ts: Date.now() });
});

// Recent REJECTS — what did the debate panel kill, with the verdict reasons.
// Useful editorial signal when accept rate is low: "are we missing good ideas
// because they were prematurely rejected?"
app.get('/api/ideas/rejects', async (_req, res) => {
  try {
    const fs = await import('node:fs/promises');
    const path = await import('node:path');
    const home = process.env.HOME || require('os').homedir() || '';
    if (!home) return res.json({ count: 0, rows: [], note: 'home dir unresolvable' });
    const p = path.join(home, '.claude', 'skills', 'idea-loop', 'data', 'rejects.jsonl');
    let raw = '';
    try { raw = await fs.readFile(p, 'utf8'); }
    catch { return res.json({ count: 0, rows: [] }); }
    const ideas = raw.split(/\r?\n/).filter(Boolean).map((line) => {
      try { return JSON.parse(line); } catch { return null; }
    }).filter(Boolean);
    // Newest first (jsonl is append-only so reverse works)
    ideas.reverse();
    res.set('Cache-Control', 'public, max-age=60');
    res.json({ count: ideas.length, rows: ideas.slice(0, 20) });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/ideas/recent', async (_req, res) => {
  try {
    const fs = await import('node:fs/promises');
    const path = await import('node:path');
    const home = process.env.HOME || require('os').homedir() || '';
    if (!home) return res.json({ count: 0, rows: [], note: 'home dir unresolvable' });
    const jsonlPath = path.join(home, '.claude', 'skills', 'idea-loop', 'data', 'ideas.jsonl');
    let raw = '';
    try { raw = await fs.readFile(jsonlPath, 'utf8'); }
    catch { return res.json({ count: 0, rows: [], note: 'idea-loop not yet running' }); }
    const ideas = raw.split(/\r?\n/).filter(Boolean).map((line) => {
      try { return JSON.parse(line); } catch { return null; }
    }).filter(Boolean);
    ideas.sort((a, b) => (b.score_total ?? 0) - (a.score_total ?? 0));
    res.set('Cache-Control', 'public, max-age=60');
    res.json({ count: ideas.length, rows: ideas.slice(0, 20) });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// 90-day daily news-fetch heatmap. /coverage.html renders it as a GitHub-
// style grid so editors see "is the scraper actually landing fresh stuff?"
app.get('/api/news/by-day', async (_req, res) => {
  try {
    const r = await query(`
      WITH days AS (
        SELECT generate_series(
          (now() AT TIME ZONE 'America/Los_Angeles')::date - interval '89 days',
          (now() AT TIME ZONE 'America/Los_Angeles')::date,
          '1 day'::interval
        )::date AS d
      )
      SELECT days.d AS day,
             COALESCE(COUNT(n.id), 0)::int AS n
        FROM days
   LEFT JOIN news_items n
          ON (n.fetched_at AT TIME ZONE 'America/Los_Angeles')::date = days.d
       GROUP BY days.d
       ORDER BY days.d ASC
    `);
    res.set('Cache-Control', 'public, max-age=600');
    res.json({ days: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Source-domain breakdown — what platforms are corridor businesses publishing
// on? wordpress.com, squarespace, custom, etc. Useful editorial signal:
// "are these mostly self-hosted blogs vs aggregator-pages?"
app.get('/api/news/sources', async (_req, res) => {
  try {
    const r = await query(`
      WITH parsed AS (
        SELECT n.id, n.business_id,
               regexp_replace(
                 substring(n.source_url FROM 'https?://(?:www\\.)?([^/]+)'),
                 '^www\\.', ''
               ) AS host
          FROM news_items n
         WHERE n.source_url ~ '^https?://'
      )
      SELECT host,
             COUNT(*)::int AS articles,
             COUNT(DISTINCT business_id)::int AS biz_count
        FROM parsed
       WHERE host IS NOT NULL
       GROUP BY host
       ORDER BY articles DESC, host ASC
       LIMIT 40
    `);
    res.set('Cache-Control', 'public, max-age=300');
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Top publishers by recency. Editors use this to spot active vendors
// (whose blogs are actually maintained) vs dormant ones. Default sort
// "fresh_7d desc, fresh_30d desc, total desc" so this week's most active
// rises to the top. Cap 50.
app.get('/api/news/leaderboard', async (_req, res) => {
  try {
    const r = await query(`
      SELECT b.id, b.name, b.city, b.category, b.website,
             COUNT(*)::int AS total,
             COUNT(*) FILTER (WHERE n.fetched_at > now() - interval '7 days')::int AS fresh_7d,
             COUNT(*) FILTER (WHERE n.fetched_at > now() - interval '30 days')::int AS fresh_30d,
             MAX(n.fetched_at) AS last_at
        FROM news_items n
        JOIN businesses b ON b.id = n.business_id
       GROUP BY b.id, b.name, b.city, b.category, b.website
       ORDER BY fresh_7d DESC, fresh_30d DESC, total DESC, b.name ASC
       LIMIT 50
    `);
    res.set('Cache-Control', 'public, max-age=300');
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// News by vertical — heatmap source for /coverage.html. Buckets by full
// OSM-style category (e.g., "amenity: restaurant", "shop: hairdresser") and
// reports both raw articles and distinct businesses publishing.
app.get('/api/news/by-vertical', async (_req, res) => {
  try {
    const r = await query(`
      WITH cat_news AS (
        SELECT b.category,
               COUNT(*) AS news_count,
               COUNT(DISTINCT n.business_id) AS biz_count
          FROM news_items n
          JOIN businesses b ON b.id = n.business_id
         WHERE b.category IS NOT NULL
         GROUP BY b.category
      )
      SELECT category,
             split_part(category, ':', 1) AS top_cat,
             news_count::int,
             biz_count::int
        FROM cat_news
       ORDER BY news_count DESC, category ASC
       LIMIT 60
    `);
    res.set('Cache-Control', 'public, max-age=300');
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// Per-business news page. When a viewer clicks the news badge from /chains,
// /buildings, the map detail panel, or /news.html, they can drill into the
// full firehose for that single business instead of getting the boulevard
// digest. Renders inline HTML to keep this self-contained (no .ejs).
app.get('/business/:id/news', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id)) return res.status(400).send('bad id');
    const biz = await query(`SELECT id, name, city, website, category FROM businesses WHERE id = $1`, [id]);
    if (!biz.rowCount) return res.status(404).send('not found');
    const b = biz.rows[0];
    const items = await query(`
      SELECT id, source_url, title, excerpt, summary, published_guess, fetched_at
        FROM news_items WHERE business_id = $1
       ORDER BY COALESCE(published_guess, fetched_at::date) DESC,
                fetched_at DESC
       LIMIT 200
    `, [id]);
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const safeUrl = (raw: string) => {
      try {
        const u = new URL(String(raw || ''));
        if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
        return esc(u.toString());
      } catch { return '#'; }
    };
    const fmt = (d: any) => d ? new Date(d).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) : '';
    const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
    const itemsHtml = items.rows.map((n: any) => {
      const text = n.summary || n.excerpt || '';
      return `
      <article class="ni">
        <h3 class="ni-title"><a href="${safeUrl(n.source_url)}" target="_blank" rel="noopener noreferrer">${esc((n.title || '(untitled)').slice(0, 200))}</a></h3>
        <p class="ni-body">${esc(text.slice(0, 500))}${text.length > 500 ? '…' : ''}</p>
        <div class="ni-meta">
          <span>${esc(fmt(n.published_guess || n.fetched_at))}</span>
          <span>·</span>
          <span>${esc(host(n.source_url))}</span>
          ${n.summary ? '<span class="ni-tag">qwen3 summary</span>' : '<span class="ni-tag muted">awaiting summary</span>'}
        </div>
      </article>`;
    }).join('');
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${esc(b.name)} — News · Ventura Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="alternate" type="application/rss+xml" title="${esc(b.name)} — Ventura Corridor news" href="/news/feed.xml?business=${id}">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2622}
*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
header.crumb{padding:14px 32px;border-bottom:1px solid var(--rule);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
header.crumb a{color:var(--metal);text-decoration:none}
.hero{padding:48px 32px 32px;border-bottom:1px solid var(--rule);max-width:920px;margin:0 auto}
.hero .kicker{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);margin-bottom:8px}
.hero h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:48px;line-height:1.05;margin:0;color:var(--ink)}
.hero h1 em{font-style:normal;color:var(--metal-glow)}
.hero .meta{margin-top:14px;font-size:13px;color:var(--ink-mute)}
.hero .meta a{color:var(--metal);text-decoration:none}
main{max-width:920px;margin:0 auto;padding:32px}
.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:18px}
.ni{padding:24px 0;border-bottom:1px dotted var(--rule);display:flex;flex-direction:column;gap:8px}
.ni-title{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:24px;line-height:1.2;margin:0}
.ni-title a{color:var(--ink);text-decoration:none}
.ni-title a:hover{color:var(--metal-glow)}
.ni-body{font-size:14px;line-height:1.55;color:var(--ink-mute);margin:0}
.ni-meta{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;color:var(--ink-mute);text-transform:uppercase;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.ni-tag{padding:2px 6px;border:1px solid var(--rule);color:var(--metal-glow)}
.ni-tag.muted{color:var(--ink-mute)}
</style>
</head><body>
<header class="crumb">
  <a href="/news.html">← all corridor news</a>
</header>
<section class="hero">
  <p class="kicker">${esc(b.category || 'corridor')}${b.city ? ' · ' + esc(b.city) : ''}</p>
  <h1>News from <em>${esc(b.name)}</em></h1>
  <p class="meta">${items.rowCount} article${items.rowCount === 1 ? '' : 's'} scraped from
    ${b.website ? `<a href="${safeUrl(b.website)}" target="_blank" rel="noopener noreferrer">${esc(host(b.website) || b.website)}</a>` : 'their website'}
    · <a href="/news/feed.xml?business=${id}" style="color:var(--metal-glow);text-decoration:none;border-bottom:1px dotted var(--metal-glow)">📡 RSS subscribe</a>
  </p>
</section>
<main>
  ${items.rowCount === 0 ? '<div class="empty">No news scraped yet. The nightly 03:15 scrape will check their site again.</div>' : itemsHtml}
</main>
</body></html>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// RSS feed of scraped news_items. Steve plugs the URL into NetNewsWire /
// Feedbin / any RSS reader and gets every fresh corridor news item without
// opening the magazine. 50-item cap, sorted by fetched_at DESC. Each item's
// description prefers the qwen3 summary when available, else the body excerpt.
app.get('/news/feed.xml', async (req, res) => {
  try {
    const _esc0 = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const baseUrl = _esc0(process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780');
    // ?vertical=amenity:restaurant or ?vertical=shop yields a category-filtered
    // feed. We normalize colons-in-URL by also accepting a hyphen form
    // (?vertical=amenity-restaurant -> "amenity: restaurant"). Empty / absent
    // value returns the firehose.
    // Accept any of: "amenity", "amenity:restaurant", "amenity: restaurant",
    // "amenity-restaurant", "amenity_restaurant". Normalize to a SQL pattern
    // that matches either "amenity: restaurant" exactly or any "amenity: %"
    // child when only the top-cat is given.
    let vertical = String(req.query.vertical || '').trim().slice(0, 60).toLowerCase();
    const businessId = parseInt(String(req.query.business || ''), 10);
    const params: any[] = [];
    const conds: string[] = [];
    // Escape ILIKE wildcards (% and _) in user input so ?vertical=% can't match
    // every category. Caught by codex-2way audit (SQL wildcard injection,
    // tick 73). Backslash-escape, then add the literal "%" suffix only on
    // top-cat single-token form.
    const sqlEscape = (s: string) => s.replace(/[\\%_]/g, '\\$&');
    if (vertical) {
      const tokens = vertical.split(/[:\-_\s]+/).filter(Boolean).map(sqlEscape);
      if (tokens.length === 1) {
        params.push(tokens[0] + ': %');
        conds.push(`b.category ILIKE $${params.length}`);
      } else if (tokens.length >= 2) {
        const exact = tokens[0] + ': ' + tokens.slice(1).join(' ');
        params.push(exact);
        conds.push(`b.category ILIKE $${params.length}`);
      }
    }
    if (Number.isFinite(businessId) && businessId > 0) {
      params.push(businessId);
      conds.push(`b.id = $${params.length}`);
    }
    const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
    const r = await query(`
      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
             n.published_guess, n.fetched_at,
             b.name AS business_name, b.city
        FROM news_items n
        JOIN businesses b ON b.id = n.business_id
       ${where}
       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
                n.fetched_at DESC
       LIMIT 50
    `, params);
    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    const items = r.rows.map((n: any) => {
      const desc = n.summary || n.excerpt || '';
      const dt = n.published_guess || n.fetched_at;
      const linkBase = (() => {
        try {
          const u = new URL(String(n.source_url || ''));
          if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString();
        } catch {}
        return baseUrl + '/news.html';
      })();
      return `
    <item>
      <title>${esc(n.business_name || '')} — ${esc((n.title || '(untitled)').slice(0, 200))}</title>
      <link>${esc(linkBase)}</link>
      <guid isPermaLink="false">vc-news-${n.id}</guid>
      <pubDate>${new Date(dt || Date.now()).toUTCString()}</pubDate>
      <category>${esc(n.city || 'corridor')}</category>
      <description>${esc(desc.slice(0, 600))}</description>
      <source url="${baseUrl}/news.html">Ventura Corridor news</source>
    </item>`;
    }).join('');
    res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
    res.setHeader('Cache-Control', 'public, max-age=300');
    let titleSuffix = '';
    let descExtra = '';
    if (Number.isFinite(businessId) && businessId > 0 && r.rowCount > 0) {
      titleSuffix = ` — ${_esc0(r.rows[0].business_name || `business ${businessId}`)}`;
      descExtra = ` Single business: ${_esc0(r.rows[0].business_name || '')}.`;
    } else if (vertical) {
      titleSuffix = ` (${_esc0(vertical)})`;
      descExtra = ` Filtered to ${_esc0(vertical)}.`;
    }
    res.send(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Ventura Corridor — News from the Boulevard${titleSuffix}</title>
    <link>${baseUrl}/news.html</link>
    <description>Scraped news, blog, and press posts from every business on Ventura Bl with a website.${descExtra}</description>
    <language>en-us</language>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
  </channel>
</rss>`);
  } catch (e: any) {
    res.status(500).send('error: ' + e.message);
  }
});

// Editorial digest — curated "this week on the boulevard" structure.
// Lead 3 = newest items with a body of 200+ chars (anything shorter is a
// thin hit-and-miss home-page slug). Tail = rest, sorted recent-first.
app.get('/api/news/digest', async (_req, res) => {
  try {
    const lead = await query(`
      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
             n.published_guess, n.fetched_at,
             b.name AS business_name, b.category, b.city
        FROM news_items n
        JOIN businesses b ON b.id = n.business_id
       WHERE length(n.body) > 200
         AND n.title IS NOT NULL AND length(n.title) > 6
       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
                n.fetched_at DESC
       LIMIT 3
    `);
    const tailIds = lead.rows.map((r: any) => r.id);
    const tail = await query(`
      SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
             n.published_guess, n.fetched_at,
             b.name AS business_name, b.category, b.city
        FROM news_items n
        JOIN businesses b ON b.id = n.business_id
       WHERE n.id NOT IN (${tailIds.length ? tailIds.map((_: any, i: number) => '$' + (i + 1)).join(',') : 'NULL'})
       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
                n.fetched_at DESC
       LIMIT 30
    `, tailIds);
    res.set('Cache-Control', 'public, max-age=300');
    res.json({ lead: lead.rows, tail: tail.rows, generated_at: new Date().toISOString() });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/news/by-business/:id', async (req, res) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!id) return res.status(400).json({ error: 'bad_id' });
    const r = await query(`
      SELECT n.id, n.source_url, n.title, n.excerpt, n.summary,
             n.published_guess, n.fetched_at
        FROM news_items n
       WHERE n.business_id = $1
       ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
                n.fetched_at DESC
    `, [id]);
    res.json({ count: r.rowCount, business_id: id, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Boulevard H1 Mural (extracted headlines per business) ───────────
// Public — editorial / cultural surface, not competitive intel.
app.get('/api/headlines', async (_req, res) => {
  try {
    const r = await query(`
      SELECT b.id, b.name, b.city, b.category, b.website, b.lat, b.lng,
             e.headline, e.headline_source,
             e.headline_voice, e.headline_voice_label
      FROM businesses b
      JOIN business_enrichment e ON e.business_id = b.id
      WHERE b.on_corridor
        AND e.headline IS NOT NULL
        AND length(e.headline) >= 4
      ORDER BY b.lng ASC  -- west-to-east along the corridor
    `);
    res.json({ count: r.rowCount, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Boulevard Timeline (films + photos merged chronologically) ──────
// Public, no auth — this is cultural / archival data, not competitive intel.
import { readFileSync as _readFileSync } from 'node:fs';
let _timelineCache: any = null;
let _timelineCacheAt = 0;
app.get('/api/timeline', (_req, res) => {
  try {
    const fresh = Date.now() - _timelineCacheAt < 60_000;
    if (fresh && _timelineCache) return res.json(_timelineCache);
    const filmsRaw = _readFileSync(path.resolve(__dirname, '../../data/photos/ventura-films.json'), 'utf8');
    const photosRaw = _readFileSync(path.resolve(__dirname, '../../data/photos/wikimedia-manifest.json'), 'utf8');
    const films = JSON.parse(filmsRaw);
    const photos = JSON.parse(photosRaw);
    const items: any[] = [];
    for (const f of films) {
      // Prefer the year embedded in the title (e.g. "Commando (1985 film)") —
      // it's the actual release year. Fall back to scanning category strings.
      const titleYear = f.title.match(/\((\d{4})/);
      const catYears = (f.categories_film_or_tv || []).map((c: string) => {
        const m = c.match(/(19|20)\d{2}/);
        return m ? parseInt(m[0]) : null;
      }).filter((y: number | null): y is number => !!y);
      const year = titleYear ? parseInt(titleYear[1]) : (catYears.length ? Math.min(...catYears) : null);
      items.push({
        type: 'film',
        kind: f.kind,
        year,
        title: f.title.replace(/\s*\(\d{4}[^)]*\)/, ''),
        snippet: (f.snippet || '').replace(/&#039;/g, "'").replace(/<[^>]+>/g, ''),
        link: f.wikipedia_url,
      });
    }
    for (const p of photos) {
      const ts = p.timestamp ? new Date(p.timestamp) : null;
      const year = ts ? ts.getFullYear() : null;
      items.push({
        type: 'photo',
        year,
        title: (p.title || '').replace(/^File:/, '').replace(/\.(jpg|jpeg|png|gif|webp)$/i, '').replace(/[-_]/g, ' '),
        artist: p.artist,
        license: p.license_short,
        thumb: p.thumb_url,
        link: p.url,
      });
    }
    items.sort((a, b) => (a.year || 9999) - (b.year || 9999));
    _timelineCache = { count: items.length, items };
    _timelineCacheAt = Date.now();
    res.json(_timelineCache);
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Marketplace — local Facebook-Marketplace-style listings ─────────
// Public (no admin gate). Read-only API; submissions go through /api/marketplace/post
// (also public, rate-limited, fail-closed if HCAPTCHA_SECRET is set).
// Migration 023 seeds ~16 sample listings so the page renders on first load.
app.get('/api/marketplace', async (req, res) => {
  try {
    const category = String(req.query.category || '').trim();
    const q        = String(req.query.q || '').trim();
    const maxPrice = Number(req.query.max_price || 0);   // cents; 0 = no cap
    const sort     = String(req.query.sort || 'newest'); // newest | price_asc | price_desc | title
    const limit    = Math.min(parseInt(String(req.query.limit || '200'), 10) || 200, 500);

    const where: string[] = [`status = 'active'`];
    const params: any[]   = [];
    if (category && category !== 'all') {
      params.push(category);
      where.push(`category = $${params.length}`);
    }
    if (q) {
      params.push(`%${q.toLowerCase()}%`);
      where.push(`(LOWER(title) LIKE $${params.length} OR LOWER(COALESCE(description,'')) LIKE $${params.length} OR LOWER(COALESCE(location_label,'')) LIKE $${params.length})`);
    }
    if (maxPrice > 0) {
      params.push(maxPrice);
      where.push(`(price_cents IS NULL OR price_cents <= $${params.length})`);
    }
    const orderBy =
      sort === 'price_asc'  ? `price_cents NULLS LAST ASC, posted_at DESC` :
      sort === 'price_desc' ? `price_cents NULLS LAST DESC, posted_at DESC` :
      sort === 'title'      ? `LOWER(title) ASC` :
                              `posted_at DESC`;
    params.push(limit);
    const sql = `
      SELECT id, category, title, description, price_cents, condition,
             location_label, lat, lng, image_url, source_url, source_platform,
             seller_name, posted_at
        FROM marketplace_listings
       WHERE ${where.join(' AND ')}
       ORDER BY ${orderBy}
       LIMIT $${params.length}
    `;
    const r = await query(sql, params);
    res.json({ rows: r.rows, count: r.rowCount });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/api/marketplace/categories', async (_req, res) => {
  try {
    const r = await query(`
      SELECT category, COUNT(*)::int AS n
        FROM marketplace_listings
       WHERE status = 'active'
       GROUP BY category
       ORDER BY n DESC, category
    `);
    const total = r.rows.reduce((a: number, b: any) => a + Number(b.n), 0);
    res.json({ total, rows: r.rows });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

app.post('/api/marketplace/post', express.json(), async (req, res) => {
  try {
    const b = req.body || {};
    const category    = String(b.category || '').trim();
    const title       = String(b.title || '').trim();
    const description = String(b.description || '').trim();
    const priceCents  = b.price_cents != null && b.price_cents !== '' ? Math.max(0, parseInt(String(b.price_cents), 10) || 0) : null;
    const condition   = b.condition && ['new','like-new','good','fair','parts'].includes(String(b.condition)) ? String(b.condition) : null;
    const location    = String(b.location_label || '').trim() || null;
    const sellerName  = String(b.seller_name || '').trim() || null;
    const contact     = String(b.seller_contact || '').trim() || null;
    if (!category || !title) {
      return res.status(400).json({ error: 'category and title required' });
    }
    if (title.length > 200 || description.length > 4000) {
      return res.status(400).json({ error: 'title or description too long' });
    }
    const r = await query(
      `INSERT INTO marketplace_listings
         (category, title, description, price_cents, condition, location_label,
          seller_name, seller_contact, source_platform, status)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'local','active')
       RETURNING id`,
      [category, title, description || null, priceCents, condition, location, sellerName, contact],
    );
    res.json({ ok: true, id: r.rows[0].id });
  } catch (e: any) {
    res.status(500).json({ error: e.message });
  }
});

// ─── Screenshots — captured front-page JPEGs from the crawler ────────
app.use('/screenshots', express.static(path.resolve(__dirname, '../../data/screenshots'), {
  maxAge: '1h',
  immutable: false,
}));

// ─── IG drafts — generated by ig_autopost_dryrun, served behind admin gate ──
app.use('/admin/ig-drafts', express.static(path.resolve(__dirname, '../../tmp/ig-drafts'), {
  maxAge: '1m',
  index: 'index.html',
}));

// ─── Daily standup audio — generated by daily_standup_audio job ──
app.use('/standup/audio', express.static(path.resolve(__dirname, '../../data/audio/standup'), { maxAge: '1m' }));

// ─── Cover images — uploaded via /api/issues/:month/cover-image, served public-ish (still loopback only) ──
app.use('/covers', express.static(path.resolve(__dirname, '../../data/covers'), { maxAge: '1h' }));
app.get('/standup', async (_req, res) => {
  const dir = path.resolve(__dirname, '../../data/audio/standup');
  const fs = await import('node:fs');
  let entries: string[] = [];
  try { entries = fs.readdirSync(dir).filter(n => /^standup-\d{4}-\d{2}-\d{2}\.m4a$/.test(n)).sort().reverse(); } catch {}
  const today = entries[0] || null;
  const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
  let scriptBody = '';
  if (today) {
    try { scriptBody = fs.readFileSync(path.join(dir, today.replace('.m4a', '.txt')), 'utf8'); } catch {}
  }
  res.type('html').send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Daily standup · The Corridor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
*{box-sizing:border-box}body{margin:0;background:#0a0a0c;color:#f0ece2;font-family:Inter,sans-serif;font-weight:300;padding:48px 24px;max-width:720px;margin:0 auto}
h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:48px;margin:0 0 6px;color:#f0ece2}
h1 em{color:#b89968;font-style:normal}
.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500;margin-bottom:14px}
.player{margin:28px 0;padding:24px;border:1px solid #2a2622;background:rgba(184,153,104,0.04)}
.player .meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:#888475;margin-bottom:14px}
audio{width:100%;border-radius:0}
.script{margin-top:20px;font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;line-height:1.6;color:#f0ece2;background:rgba(184,153,104,0.04);border:1px solid #2a2622;padding:24px;white-space:pre-wrap}
.archive{margin-top:36px}
.archive h3{font-family:'Cormorant Garamond',serif;font-style:italic;color:#b89968;margin:0 0 14px}
.archive a{display:block;font-family:'JetBrains Mono',monospace;font-size:12px;color:#888475;text-decoration:none;padding:6px 0;border-bottom:1px solid #2a2622}
.archive a:hover{color:#b89968}
nav.top{font-size:9px;letter-spacing:.32em;text-transform:uppercase;margin-bottom:24px}
nav.top a{color:#888475;text-decoration:none;margin-right:18px}
nav.top a:hover{color:#b89968}
</style></head><body>
<nav class="top"><a href="/today.html">← today</a><a href="/issue">issue</a><a href="/feedback.html">feedback</a></nav>
<div class="kicker">${new Date().toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}</div>
<h1>Daily <em>standup</em></h1>
${today ? `<div class="player">
  <div class="meta">${esc(today)}</div>
  <audio controls preload="auto" autoplay>
    <source src="/standup/audio/${esc(today)}" type="audio/mp4">
  </audio>
</div>${scriptBody ? `<div class="script">${esc(scriptBody)}</div>` : ''}
<div class="archive"><h3>Archive</h3>${entries.slice(1, 14).map(e => `<a href="/standup/audio/${esc(e)}">${esc(e)}</a>`).join('') || '<div style="color:#888475;font-style:italic;font-family:Cormorant Garamond,serif">no prior standups yet</div>'}</div>` : `<div class="player"><div class="meta">No standup audio yet. Run <code style="color:#b89968">npm run magazine:standup</code> or wait for the 7:50 AM job.</div></div>`}
</body></html>`);
});

// ─── Static viewer ─────────────────────────────────────────────────────
app.use('/', express.static(path.resolve(__dirname, '../../public')));

// Friendly 404 — magazine-flavored, suggests likely routes + a "lottery" pick
app.use(async (req, res) => {
  if (req.accepts('html')) {
    const escapeHtml = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
    let lottery: { id: number; headline: string; subhead: string; biz: string } | null = null;
    try {
      const r = await query(
        `SELECT mf.id, mf.headline, mf.subhead, b.name AS biz
         FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
         WHERE mf.status = 'published' AND length(mf.editorial) > 240
         ORDER BY random() LIMIT 1`
      );
      if (r.rowCount) lottery = r.rows[0];
    } catch {}
    const lotteryHtml = lottery ? `
  <div style="margin-top:48px;padding:28px 24px;border-top:1px solid #d8cdb8;border-bottom:1px solid #d8cdb8;background:rgba(184,153,104,0.05);text-align:left">
    <div class="kicker" style="text-align:center;margin-bottom:14px">A feature you may not have read</div>
    <div style="font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:32px;line-height:1.05;color:#1a1815;margin-bottom:8px"><a href="/magazine/${lottery.id}" style="color:inherit;text-decoration:none;border-bottom:1px solid transparent" onmouseover="this.style.borderColor='#8a6d3b'" onmouseout="this.style.borderColor='transparent'">${escapeHtml(lottery.headline)}</a></div>
    <div style="font-family:'Cormorant Garamond',serif;font-style:italic;color:#6e6356;font-size:15px;line-height:1.4;margin-bottom:6px">${escapeHtml(lottery.subhead || '')}</div>
    <div style="font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:#8a6d3b">${escapeHtml(lottery.biz)}</div>
  </div>` : '';
    res.status(404).type('html').send(`<!doctype html><html><head><meta charset="utf-8">
<title>Not in this issue · The Corridor</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:#faf6ee;color:#1a1815;font-family:'Inter',sans-serif;font-weight:300;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{max-width:520px;text-align:center}
.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500}
h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:14px 0 12px;letter-spacing:-0.02em}
h1 em{font-style:normal;color:#8a6d3b}
.deck{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;color:#6e6356;line-height:1.4;margin-bottom:32px}
.path{font-family:'JetBrains Mono',monospace;font-size:12px;color:#888;background:rgba(184,153,104,0.08);padding:6px 12px;display:inline-block;margin-bottom:24px}
nav{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:16px}
nav a{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:#8a6d3b;text-decoration:none;border:1px solid #d8cdb8;padding:8px 16px}
nav a:hover{color:#6a3a1a;border-color:#6a3a1a}
</style></head><body>
<div class="card">
  <div class="kicker">The Corridor · 404</div>
  <h1>Not in <em>this issue</em>.</h1>
  <div class="deck">The page you were looking for didn't make the cut. Try one of these.</div>
  <div class="path">${req.path.slice(0, 80)}</div>
  <nav>
    <a href="/issue">Read the issue</a>
    <a href="/search.html">Search</a>
    <a href="/scoreboard.html">Scoreboard</a>
    <a href="/words">Words</a>
    <a href="/rate-card.html">Rates</a>
  </nav>
  ${lotteryHtml}
</div>
</body></html>`);
  } else {
    res.status(404).type('json').send({ error: 'not found', path: req.path });
  }
});

// 127.0.0.1 only — directory data stays local per the lawyer/doctor precedent.
app.listen(PORT, '127.0.0.1', () => {
  console.log(`[ventura-corridor] listening on http://127.0.0.1:${PORT}`);
});