[object Object]

← back to Ventura Corridor

feat(news): per-business RSS via /news/feed.xml?business=N

3b4d52317c2007de5ce185628daea147eb33fca0 · 2026-05-07 18:28:07 -0700 · SteveStudio2

Same RSS handler now optionally filters to a single business. Combines
with vertical: ?business=49&vertical=shop returns the AND.

Routes (all on the existing /news/feed.xml):
  /news/feed.xml                       firehose, 50 most recent
  /news/feed.xml?vertical=amenity      top-cat filter
  /news/feed.xml?vertical=shop:bakery  exact subcategory filter
  /news/feed.xml?business=N            single-business feed
  /news/feed.xml?business=N&vertical=X intersection (AND)

UI:
  /business/:id/news <head>            <link rel=alternate type=rss>
                                       so feed readers auto-subscribe
                                       when the user opens the page
  /business/:id/news header            "📡 RSS subscribe" anchor next
                                       to the website link

Channel <title> dynamically suffixes the business_name when filtered
to one biz, so feed-reader panes label correctly.

Smoke: biz 49 (Solar Unlimited Encino, 4 articles) feed validates
with xmllint, channel title contains the business name, /business/49/news
includes the subscribe button.

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

Files touched

Diff

commit 3b4d52317c2007de5ce185628daea147eb33fca0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 18:28:07 2026 -0700

    feat(news): per-business RSS via /news/feed.xml?business=N
    
    Same RSS handler now optionally filters to a single business. Combines
    with vertical: ?business=49&vertical=shop returns the AND.
    
    Routes (all on the existing /news/feed.xml):
      /news/feed.xml                       firehose, 50 most recent
      /news/feed.xml?vertical=amenity      top-cat filter
      /news/feed.xml?vertical=shop:bakery  exact subcategory filter
      /news/feed.xml?business=N            single-business feed
      /news/feed.xml?business=N&vertical=X intersection (AND)
    
    UI:
      /business/:id/news <head>            <link rel=alternate type=rss>
                                           so feed readers auto-subscribe
                                           when the user opens the page
      /business/:id/news header            "📡 RSS subscribe" anchor next
                                           to the website link
    
    Channel <title> dynamically suffixes the business_name when filtered
    to one biz, so feed-reader panes label correctly.
    
    Smoke: biz 49 (Solar Unlimited Encino, 4 articles) feed validates
    with xmllint, channel title contains the business name, /business/49/news
    includes the subscribe button.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/server/index.ts | 25 ++++++++++++++++++++-----
 1 file changed, 20 insertions(+), 5 deletions(-)

diff --git a/src/server/index.ts b/src/server/index.ts
index 075420b..0890155 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5149,6 +5149,7 @@ app.get('/business/:id/news', async (req, res) => {
 <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}
@@ -5181,6 +5182,7 @@ main{max-width:920px;margin:0 auto;padding:32px}
   <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>
@@ -5209,19 +5211,25 @@ app.get('/news/feed.xml', async (req, res) => {
     // 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[] = [];
-    let where = '';
+    const conds: string[] = [];
     if (vertical) {
       const tokens = vertical.split(/[:\-_\s]+/).filter(Boolean);
       if (tokens.length === 1) {
         params.push(tokens[0] + ': %');
-        where = `WHERE b.category ILIKE $1`;
+        conds.push(`b.category ILIKE $${params.length}`);
       } else if (tokens.length >= 2) {
         const exact = tokens[0] + ': ' + tokens.slice(1).join(' ');
         params.push(exact);
-        where = `WHERE b.category ILIKE $1`;
+        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,
@@ -5257,8 +5265,15 @@ app.get('/news/feed.xml', async (req, res) => {
     }).join('');
     res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
     res.setHeader('Cache-Control', 'public, max-age=300');
-    const titleSuffix = vertical ? ` (${_esc0(vertical)})` : '';
-    const descExtra = vertical ? ` Filtered to ${_esc0(vertical)}.` : '';
+    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>

← 52178df feat(news): per-vertical RSS feeds via /news/feed.xml?vertic  ·  back to Ventura Corridor  ·  feat(news): recency badge (Hot/Fresh/Stale) on map detail pa 67f7514 →