[object Object]

← back to Ventura Corridor

feat(news): /business/:id/news per-business news firehose page

5afdd26bd2a239456273d38ba24e35aa89957747 · 2026-05-07 15:29:45 -0700 · SteveStudio2

Drill-down view from any news rail. When a viewer clicks the news badge
on /chains, /buildings, the map detail panel, or /news.html, they can
land on a dedicated per-business page showing every article scraped
from that business website (full body excerpts, dates, source domain,
qwen3-summary tag when present).

Routes:
  GET /business/:id/news    inline-rendered HTML, no template, paper-noir
                            style matching /news.html. 404 on bad id,
                            graceful empty state when news_items is zero
                            ("nightly 03:15 scrape will check again").

Hardening:
  - safeUrl() scheme-validates http(s) before <a href> interpolation
  - esc() applied to all biz-attribute values (carry-over hardening
    pattern from tick 43 XSS fix)

Discovery wired:
  - public/index.html showPanel news rail: "all -->" link in header
  - public/buildings.html badge: now points at /business/<id>/news
    (was /news.html#biz-<id> which was a non-existent anchor)

Path whitelisted in ADMIN_PATHS regex; smoke confirms 200 with content,
404 on bogus id, empty-state on real id with no news.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5afdd26bd2a239456273d38ba24e35aa89957747
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 15:29:45 2026 -0700

    feat(news): /business/:id/news per-business news firehose page
    
    Drill-down view from any news rail. When a viewer clicks the news badge
    on /chains, /buildings, the map detail panel, or /news.html, they can
    land on a dedicated per-business page showing every article scraped
    from that business website (full body excerpts, dates, source domain,
    qwen3-summary tag when present).
    
    Routes:
      GET /business/:id/news    inline-rendered HTML, no template, paper-noir
                                style matching /news.html. 404 on bad id,
                                graceful empty state when news_items is zero
                                ("nightly 03:15 scrape will check again").
    
    Hardening:
      - safeUrl() scheme-validates http(s) before <a href> interpolation
      - esc() applied to all biz-attribute values (carry-over hardening
        pattern from tick 43 XSS fix)
    
    Discovery wired:
      - public/index.html showPanel news rail: "all -->" link in header
      - public/buildings.html badge: now points at /business/<id>/news
        (was /news.html#biz-<id> which was a non-existent anchor)
    
    Path whitelisted in ADMIN_PATHS regex; smoke confirms 200 with content,
    404 on bogus id, empty-state on real id with no news.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/buildings.html |  2 +-
 public/index.html     |  5 ++-
 src/server/index.ts   | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 96 insertions(+), 2 deletions(-)

diff --git a/public/buildings.html b/public/buildings.html
index c1aefa7..5efbd7d 100644
--- a/public/buildings.html
+++ b/public/buildings.html
@@ -357,7 +357,7 @@ async function toggleBldg(addrEl) {
         ${rows.map(r => `
           <tr>
             <td class="suite">${escapeHtml(r.suite || '')}</td>
-            <td class="name">${escapeHtml(r.name)}${dupeBadge(r.business_id)}${r.news_count > 0 ? `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'}"><a href="/news.html#biz-${r.business_id}" style="color:inherit;text-decoration:none">📰 ${r.news_count}</a></span>` : ''}</td>
+            <td class="name">${escapeHtml(r.name)}${dupeBadge(r.business_id)}${r.news_count > 0 ? `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'}"><a href="/business/${r.business_id}/news" style="color:inherit;text-decoration:none">📰 ${r.news_count}</a></span>` : ''}</td>
             <td class="naics">${escapeHtml((r.naics || '').slice(0, 50))}</td>
             <td>${r.pitch_id
               ? `<span class="pill ${r.status}">${escapeHtml(r.status)}</span>`
diff --git a/public/index.html b/public/index.html
index 01c9313..9277400 100644
--- a/public/index.html
+++ b/public/index.html
@@ -337,7 +337,10 @@ async function showPanel(id) {
       }).join('');
       return `
       <div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">
-        <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal);margin-bottom:6px">📰 News from their site · ${newsItems.length}</div>
+        <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
+          <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)">📰 News from their site · ${newsItems.length}</div>
+          <a href="/business/${id}/news" style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal-glow);text-decoration:none;border-bottom:1px dotted var(--metal-glow)">all →</a>
+        </div>
         ${items}
       </div>`;
     })() : '';
diff --git a/src/server/index.ts b/src/server/index.ts
index 50baca8..0bd59d5 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -114,6 +114,7 @@ const ADMIN_PATHS = [
   /^\/api\/buildings(\.csv|\/.*)?$/i,
   /^\/news(\.html)?\/?$/i,
   /^\/news\/feed\.xml\/?$/i,
+  /^\/business\/\d+\/news\/?$/i,
   /^\/this-week(\.html)?\/?$/i,
   /^\/api\/news(\/.*)?$/i,
   /^\/pitch\/\d+\/?$/i,
@@ -5016,6 +5017,96 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// 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
+    `, [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">
+<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'}
+  </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

← b329ecf feat(news): RSS feed at /news/feed.xml + auto-discovery on /  ·  back to Ventura Corridor  ·  feat(news): /api/news/by-vertical + heatmap on /coverage.htm ec89429 →