[object Object]

← back to Small Business Builder

[morning-review] small-business-builder: harden public profile + interview API

b789cdc5ea3e47bae8eda608d3927b159b0b1d54 · 2026-05-04 12:51:11 -0700 · SteveStudio2

- Strip third-party mcp.figma.com/capture.js from public home + profile pages
- Escape JSON-LD payloads so user answers can't break out of <script> (XSS)
- Wrap new URL(b.website) so scheme-less domains don't 500 the profile route
- Gate POST /api/biz/interview behind ADMIN_TOKEN cookie + admin_token field;
  reject Array qa payloads, accept urlencoded qa_<id> for no-JS form fallback,
  redirect non-JSON callers, trim history to 5 most-recent rows per business
- Cap homepage SELECT at 200 rows; replace /n/:slug full-table scan with
  WHERE neighborhood=$1 + DISTINCT slug resolution; 409 on slug collisions
- Guard Number.isFinite on years_in_business, recorded_at date format,
  empty hoodSlug links, NaN max_chars in interview form
- Move interview-form submit button inside <form> so no-JS submit + a11y work

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

Files touched

Diff

commit b789cdc5ea3e47bae8eda608d3927b159b0b1d54
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 12:51:11 2026 -0700

    [morning-review] small-business-builder: harden public profile + interview API
    
    - Strip third-party mcp.figma.com/capture.js from public home + profile pages
    - Escape JSON-LD payloads so user answers can't break out of <script> (XSS)
    - Wrap new URL(b.website) so scheme-less domains don't 500 the profile route
    - Gate POST /api/biz/interview behind ADMIN_TOKEN cookie + admin_token field;
      reject Array qa payloads, accept urlencoded qa_<id> for no-JS form fallback,
      redirect non-JSON callers, trim history to 5 most-recent rows per business
    - Cap homepage SELECT at 200 rows; replace /n/:slug full-table scan with
      WHERE neighborhood=$1 + DISTINCT slug resolution; 409 on slug collisions
    - Guard Number.isFinite on years_in_business, recorded_at date format,
      empty hoodSlug links, NaN max_chars in interview form
    - Move interview-form submit button inside <form> so no-JS submit + a11y work
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/render/home.js    |  58 +++++++++++++++-
 src/render/profile.js |  56 ++++++++++++----
 src/server/index.js   | 179 +++++++++++++++++++++++++++++++++++++++++++-------
 3 files changed, 255 insertions(+), 38 deletions(-)

diff --git a/src/render/home.js b/src/render/home.js
index e2cae90..f5e60fd 100644
--- a/src/render/home.js
+++ b/src/render/home.js
@@ -106,8 +106,47 @@ function renderNeighborhoodTile(n) {
   </a>`;
 }
 
+// Build the featured-interview block, if any.
+// Pull the philosophy + best neighborhood-section answer (favorite restaurant
+// or coffee — these read best as a teaser) and link to the full profile.
+function renderFeaturedInterview(featured) {
+  if (!featured || !featured.business || !featured.qa_json) return '';
+  const b = featured.business;
+  const qa = featured.qa_json;
+  const philosophy = qa.philosophy;
+  // Pick the most appealing teaser from the neighborhood section.
+  const teaserKey = ['fav_restaurant','best_coffee','after_the_chair','hidden_gem','date_night']
+    .find(k => qa[k] && qa[k].trim());
+  const teaserMap = {
+    fav_restaurant:  'Where they actually eat',
+    best_coffee:     'Best coffee, no diplomacy',
+    after_the_chair: '30 minutes to kill, post-cut',
+    hidden_gem:      'Hidden gem, no tourists',
+    date_night:      'Where to take a date',
+  };
+  if (!philosophy && !teaserKey) return '';
+  const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
+  return `
+  <section class="featured" style="--feat-accent:${accent}">
+    <div class="featured-inner">
+      <div class="featured-eyebrow">In this issue · <a href="/biz/${esc(b.slug)}">${esc(b.name)}</a> · ${esc(b.neighborhood || 'Los Angeles')}</div>
+      ${philosophy ? `
+        <blockquote class="featured-quote">"${esc(philosophy)}"</blockquote>
+        <div class="featured-attr">— ${esc(b.owner_name || b.name)}</div>
+      ` : ''}
+      ${teaserKey ? `
+        <div class="featured-teaser">
+          <div class="featured-teaser-label">${esc(teaserMap[teaserKey])}</div>
+          <p class="featured-teaser-text">${esc(qa[teaserKey])}</p>
+        </div>
+      ` : ''}
+      <a class="featured-cta" href="/biz/${esc(b.slug)}">Read the full interview →</a>
+    </div>
+  </section>`;
+}
+
 // businesses: full list, neighborhoods: [{neighborhood, count}], featuredBarbers: shops where category in barbershop/beard
-export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], totalCount = 0 } = {}) {
+export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], featured = null, totalCount = 0 } = {}) {
   // Sort neighborhood list by editorial priority, then by count desc, then alphabetical
   const orderIdx = (n) => {
     const i = NAV_ORDER.indexOf(n);
@@ -131,7 +170,6 @@ export function renderHome({ featuredBarbers = [], otherShops = [], neighborhood
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
-<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
 <style>
 :root {
   --ink:#1a1a1a;
@@ -190,6 +228,20 @@ img{display:block;max-width:100%;height:auto}
 .shop-name{font-family:var(--serif);font-size:22px;font-weight:500;letter-spacing:-.01em;line-height:1.15;margin-bottom:4px}
 .shop-loc{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
 
+/* ==== Featured interview ======================================== */
+.featured{padding:80px 32px;background:var(--ink);color:var(--paper);border-top:1px solid var(--ink);border-bottom:1px solid var(--ink)}
+.featured-inner{max-width:880px;margin:0 auto}
+.featured-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:#aaa;margin-bottom:32px}
+.featured-eyebrow a{color:var(--feat-accent,var(--accent));border-bottom:1px solid var(--feat-accent,var(--accent));padding-bottom:1px}
+.featured-quote{font-family:var(--serif);font-style:italic;font-size:clamp(28px,3.6vw,46px);line-height:1.2;letter-spacing:-.01em;font-weight:400;margin-bottom:18px}
+.featured-quote::first-letter{color:var(--feat-accent,var(--accent));font-style:normal;font-size:1.3em}
+.featured-attr{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#888;margin-bottom:48px}
+.featured-teaser{margin-bottom:36px;padding-left:24px;border-left:2px solid var(--feat-accent,var(--accent))}
+.featured-teaser-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--feat-accent,var(--accent));margin-bottom:10px}
+.featured-teaser-text{font-family:var(--serif);font-size:21px;line-height:1.5;color:#e6e3dc}
+.featured-cta{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--feat-accent,var(--accent));border:1px solid var(--feat-accent,var(--accent));padding:14px 22px}
+.featured-cta:hover{background:var(--feat-accent,var(--accent));color:var(--ink)}
+
 /* ==== Manifesto strip =========================================== */
 .manifesto{padding:80px 32px;background:var(--ink);color:var(--paper);border-top:1px solid var(--ink)}
 .manifesto-inner{max-width:880px;margin:0 auto}
@@ -231,6 +283,8 @@ img{display:block;max-width:100%;height:auto}
   </div>
 </section>
 
+${renderFeaturedInterview(featured)}
+
 <section class="section" id="neighborhoods">
   <div class="section-head">
     <h2>Browse by neighborhood</h2>
diff --git a/src/render/profile.js b/src/render/profile.js
index c6700b5..c7852e3 100644
--- a/src/render/profile.js
+++ b/src/render/profile.js
@@ -28,6 +28,24 @@ const CATEGORY_LABEL = {
   blowout:'Blowout', waxing:'Waxing', generic:'Salon',
 };
 
+// Escape a JSON string for safe embedding inside <script type="application/ld+json">.
+// Without this, an answer containing "</script>" can break out of the script tag (XSS).
+function escJsonLd(json) {
+  return String(json).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
+}
+
+// Safely extract a website hostname; the DB may contain bare domains like "foo.com"
+// without a scheme, which would cause `new URL(...)` to throw.
+function safeHostname(raw) {
+  if (!raw) return '';
+  const s = String(raw).trim();
+  try {
+    return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`).hostname.replace(/^www\./, '');
+  } catch {
+    return s.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('/')[0];
+  }
+}
+
 // Build the FAQ schema.org JSON-LD blob from filled answers.
 function faqJsonLd(filled) {
   const mainEntity = filled.map(({ q, a }) => ({
@@ -95,9 +113,8 @@ export function renderProfile({ business, interview, otherShopsInHood = [] } = {
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
-<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
-<script type="application/ld+json">${localBusinessJsonLd(b)}</script>
-${filled.length ? `<script type="application/ld+json">${faqJsonLd(filled)}</script>` : ''}
+<script type="application/ld+json">${escJsonLd(localBusinessJsonLd(b))}</script>
+${filled.length ? `<script type="application/ld+json">${escJsonLd(faqJsonLd(filled))}</script>` : ''}
 <style>
 :root{
   --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:${accent};
@@ -186,22 +203,22 @@ img{display:block;max-width:100%;height:auto}
   <div class="brand"><a href="/">LA <em>Salon</em> Directory</a></div>
   <nav class="nav-mini">
     <a href="/">Home</a>
-    ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : ''}
+    ${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : (b.neighborhood ? `<span>${esc(b.neighborhood)}</span>` : '')}
     <a href="/biz/${esc(b.slug)}/template/1">Mockup</a>
   </nav>
 </header>
 
 <section class="hero">
-  <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : ''}${esc(cat)}</div>
+  <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : (b.neighborhood ? `${esc(b.neighborhood)} · ` : '')}${esc(cat)}</div>
   <div class="hero-grid">
     <div class="hero-meta">
       <span class="cat">${esc(cat)}</span>
       <h1>${esc(b.name)}</h1>
-      <div class="loc">${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : ''}${esc(b.city || 'Los Angeles')}</div>
+      <div class="loc">${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : (b.neighborhood ? `${esc(b.neighborhood)}, ` : '')}${esc(b.city || 'Los Angeles')}</div>
       <div class="hero-stats">
-        ${b.years_in_business ? `<div>Established<b>${b.years_in_business} yrs</b></div>` : ''}
+        ${Number.isFinite(Number(b.years_in_business)) && Number(b.years_in_business) > 0 ? `<div>Established<b>${esc(String(Number(b.years_in_business)))} yrs</b></div>` : ''}
         ${b.address ? `<div>Address<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0">${esc(b.address)}</b></div>` : ''}
-        ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc((new URL(b.website)).hostname.replace('www.',''))}</a></b></div>` : ''}
+        ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc(safeHostname(b.website) || b.website)}</a></b></div>` : ''}
       </div>
     </div>
     <div class="hero-img">
@@ -220,7 +237,8 @@ ${philosophy ? `
 ${filled.length === 0 ? `
 <section class="no-interview">
   <p>This profile is waiting for its interview.</p>
-  <a href="/biz/${esc(b.slug)}/interview">Add the interview →</a>
+  <a href="/biz/${esc(b.slug)}/interview/live" style="margin-right:10px;background:var(--accent);color:var(--paper);border-color:var(--accent)">🎙 Start live AI interview</a>
+  <a href="/biz/${esc(b.slug)}/interview">Or fill the form →</a>
 </section>
 ` : `
 <article class="interview" itemscope itemtype="https://schema.org/FAQPage">
@@ -245,8 +263,12 @@ ${filled.length === 0 ? `
     </div>`;
   }).join('')}
   <div style="margin-top:32px;padding-top:18px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);text-align:right">
-    Recorded ${interview?.recorded_at ? new Date(interview.recorded_at).toLocaleDateString('en-US',{month:'long',year:'numeric'}) : ''}
-    ${interview?.recorded_by ? `· by ${esc(interview.recorded_by)}` : ''}
+    ${(() => {
+      if (!interview?.recorded_at) return interview?.recorded_by ? `Recorded · by ${esc(interview.recorded_by)}` : '';
+      const d = new Date(interview.recorded_at);
+      const dateStr = isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US',{month:'long',year:'numeric'});
+      return `Recorded ${dateStr}${interview?.recorded_by ? ` · by ${esc(interview.recorded_by)}` : ''}`;
+    })()}
   </div>
 </article>
 `}
@@ -255,7 +277,7 @@ ${otherShopsInHood.length ? `
 <section class="more">
   <div class="more-head">
     <h2>More in ${esc(b.neighborhood || 'Los Angeles')}</h2>
-    <a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>
+    ${hoodSlug ? `<a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>` : ''}
   </div>
   <div class="more-grid">
     ${otherShopsInHood.slice(0, 4).map(s => `
@@ -355,20 +377,26 @@ h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em
       ${sec.questions.map(q => {
         const lq = localizeQuestion(q, b);
         const v = qa[q.id] || '';
+        const maxChars = Number.isFinite(q.max_chars) && q.max_chars > 0 ? q.max_chars : 500;
         return `
         <div class="qa">
           <label for="q_${esc(q.id)}">${esc(lq.q)}</label>
-          <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${q.max_chars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
-          <div class="hint">~${q.max_chars} chars · ${esc(q.seo_intent)}</div>
+          <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${maxChars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
+          <div class="hint">~${maxChars} chars · ${esc(q.seo_intent || '')}</div>
         </div>`;
       }).join('')}
     `).join('')}
 
+    <div style="margin-top:16px;text-align:right">
+      <button type="submit" style="padding:11px 24px;background:var(--accent);color:var(--paper);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-weight:500">Save interview</button>
+    </div>
   </form>
 
   <div class="bar">
     <span class="saved" id="status">Edit anywhere — save when ready.</span>
     <span>
+      <a href="/biz/${esc(b.slug)}/interview/live">🎙 Live mode</a>
+      &nbsp;&nbsp;
       <a href="/biz/${esc(b.slug)}">View profile</a>
       &nbsp;&nbsp;
       <button type="submit" form="form">Save interview</button>
diff --git a/src/server/index.js b/src/server/index.js
index 56afb79..688039f 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -29,6 +29,16 @@ import { renderGrid } from '../render/grid.js';
 import { renderEdit } from '../render/edit.js';
 import { renderHome } from '../render/home.js';
 import { renderProfile, renderInterviewForm } from '../render/profile.js';
+import { renderInterviewLive } from '../render/interview-live.js';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const PUBLIC_DIR = path.resolve(__dirname, '..', '..', 'public');
+const UPLOADS_DIR = path.join(PUBLIC_DIR, 'uploads');
+fs.mkdirSync(UPLOADS_DIR, { recursive: true });
 import { renderTemplate } from '../render/templates/index.js';
 import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
 import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
@@ -38,9 +48,12 @@ const PORT = parseInt(process.env.PORT || '9760', 10);
 const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
 
 app.use(compression());
-app.use(express.json({ limit: '512kb' }));
+// Bumped to 8MB so webcam-frame data URLs can POST without 413.
+app.use(express.json({ limit: '8mb' }));
 app.use(express.urlencoded({ extended: true, limit: '512kb' }));
 app.use(cookieParser());
+// Serve uploaded webcam captures + any other public assets.
+app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d' }));
 
 // --- helpers ----------------------------------------------------------------
 
@@ -79,10 +92,23 @@ app.get('/healthz', async (_req, res) => {
 // Public editorial homepage — neighborhood-led, barber/beard featured first.
 app.get('/', async (_req, res) => {
   const all = await many(
-    `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
+    `SELECT slug,name,owner_name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
      FROM businesses
-     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC`
+     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
+     LIMIT 200`
+  );
+  // Latest filled interview drives the "In this issue" featured block.
+  const featured = await one(
+    `SELECT i.qa_json, i.recorded_at, b.slug, b.name, b.owner_name, b.neighborhood
+     FROM business_interviews i
+     JOIN businesses b ON b.id = i.business_id
+     WHERE jsonb_typeof(i.qa_json) = 'object' AND i.qa_json <> '{}'::jsonb
+     ORDER BY i.recorded_at DESC LIMIT 1`
   );
+  const featuredBlock = featured ? {
+    business: { slug: featured.slug, name: featured.name, owner_name: featured.owner_name, neighborhood: featured.neighborhood },
+    qa_json: featured.qa_json,
+  } : null;
   const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
   const featuredBarbers = all.filter(isBarber).slice(0, 8);
   const featuredIds = new Set(featuredBarbers.map(b => b.slug));
@@ -97,7 +123,7 @@ app.get('/', async (_req, res) => {
   const neighborhoods = [...hoodMap.entries()].map(([neighborhood, count]) => ({ neighborhood, count }));
 
   res.set('content-type', 'text/html; charset=utf-8');
-  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, totalCount: all.length }));
+  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, featured: featuredBlock, totalCount: all.length }));
 });
 
 // Original mockup-grid (admin/builder UI) lives at /builder now.
@@ -113,26 +139,40 @@ app.get('/builder', async (_req, res) => {
 // Neighborhood detail page — list the shops in one LA neighborhood, editorially.
 app.get('/n/:slug', async (req, res) => {
   const slug = String(req.params.slug || '').toLowerCase();
-  // resolve slug → neighborhood name (case-insensitive, dash → space)
-  const all = await many(
+  // First, resolve slug → distinct neighborhood name(s). Pull only the unique
+  // neighborhood strings rather than every business row, so this stays cheap
+  // even as the catalog grows.
+  const hoodRows = await many(
+    `SELECT DISTINCT neighborhood FROM businesses WHERE neighborhood IS NOT NULL`
+  );
+  const matchedHoods = hoodRows
+    .map(r => r.neighborhood)
+    .filter(n => n && n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
+  if (!matchedHoods.length) return res.status(404).send('Neighborhood not found');
+  if (matchedHoods.length > 1) {
+    log.err('neighborhood slug collision', { slug, matched: matchedHoods });
+    return res.status(409).send(`Ambiguous neighborhood — ${matchedHoods.length} match this slug`);
+  }
+  const neighborhood = matchedHoods[0];
+  const shops = await many(
     `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
-     FROM businesses WHERE neighborhood IS NOT NULL
-     ORDER BY ranking_score DESC NULLS LAST`
+     FROM businesses WHERE neighborhood=$1
+     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
+     LIMIT 200`,
+    [neighborhood]
+  );
+  const hoodCounts = await many(
+    `SELECT neighborhood, COUNT(*)::int AS count FROM businesses
+     WHERE neighborhood IS NOT NULL AND neighborhood <> $1
+     GROUP BY neighborhood ORDER BY count DESC LIMIT 50`,
+    [neighborhood]
   );
-  const match = all.find(b => b.neighborhood &&
-    b.neighborhood.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
-  if (!match) return res.status(404).send('Neighborhood not found');
-  const neighborhood = match.neighborhood;
-  const shops = all.filter(b => b.neighborhood === neighborhood);
-  const otherHoods = [...new Set(all.map(b => b.neighborhood))].filter(n => n !== neighborhood);
   // We render via renderHome with featured=barbers-in-this-hood + other=rest, slightly repurposed.
   const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
   const featuredBarbers = shops.filter(isBarber);
   const featuredIds = new Set(featuredBarbers.map(b => b.slug));
   const otherShops = shops.filter(b => !featuredIds.has(b.slug));
-  const neighborhoods = otherHoods.map(n => ({
-    neighborhood: n, count: all.filter(b => b.neighborhood === n).length,
-  }));
+  const neighborhoods = hoodCounts.map(r => ({ neighborhood: r.neighborhood, count: r.count }));
 
   res.set('content-type', 'text/html; charset=utf-8');
   res.send(renderHome({
@@ -172,24 +212,119 @@ app.get('/biz/:slug/interview', async (req, res) => {
   res.send(renderInterviewForm({ business: b, interview }));
 });
 
-// Save (newest-wins; we keep history rows).
+// Live AI-avatar interview — webcam + speech. Browser-native, no server-side LLM.
+app.get('/biz/:slug/interview/live', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  const interview = await one(
+    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
+     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
+  );
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderInterviewLive({ business: b, interview }));
+});
+
+// Webcam frame upload — saves a JPEG into /uploads, attaches as candidate hero.
+app.post('/api/biz/photo/upload', async (req, res) => {
+  try {
+    const { slug, dataUrl, kind } = req.body || {};
+    if (!slug || !dataUrl) return res.status(400).json({ error: 'slug + dataUrl required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'business not found' });
+    // Decode base64 from data URL.
+    const m = String(dataUrl).match(/^data:(image\/(?:jpeg|png|webp));base64,(.+)$/);
+    if (!m) return res.status(400).json({ error: 'invalid dataUrl (need image/jpeg|png|webp)' });
+    const mime = m[1];
+    const ext = mime === 'image/png' ? 'png' : (mime === 'image/webp' ? 'webp' : 'jpg');
+    const buf = Buffer.from(m[2], 'base64');
+    if (buf.length > 6 * 1024 * 1024) return res.status(413).json({ error: 'image too large' });
+    const filename = `${slug}-${Date.now()}.${ext}`;
+    const fullPath = path.join(UPLOADS_DIR, filename);
+    fs.writeFileSync(fullPath, buf);
+    const url = `/uploads/${filename}`;
+    // If the business has no hero_image_url yet, adopt this one.
+    if (!b.hero_image_url) {
+      await query('UPDATE businesses SET hero_image_url=$1 WHERE id=$2', [url, b.id]);
+    }
+    log.info('photo uploaded', { slug, url, bytes: buf.length, kind: kind || 'unspecified' });
+    res.json({ ok: true, url, bytes: buf.length, adopted_as_hero: !b.hero_image_url });
+  } catch (e) {
+    log.err('photo upload failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
+// Save (newest-wins; we keep a rolling history capped at INTERVIEW_HISTORY_MAX rows).
+// Auth: gated by the same ADMIN_TOKEN cookie as /admin so anonymous visitors
+// can't overwrite arbitrary shop interviews.
 app.post('/api/biz/interview', async (req, res) => {
   try {
-    const { slug, qa, recorded_by } = req.body || {};
-    if (!slug || !qa || typeof qa !== 'object') return res.status(400).json({ error: 'slug + qa required' });
+    const tok = req.cookies?.smb_admin || req.body?.admin_token || req.query?.t || '';
+    if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
+      return res.status(401).json({ error: 'unauthorized' });
+    }
+
+    const body = req.body || {};
+    const slug = body.slug;
+    const recorded_by = body.recorded_by;
+
+    // Accept either JSON `{ qa: { id: "answer", ... } }` (the JS path) OR a no-JS
+    // urlencoded form post with `qa_<id>` keys. Reject arrays — `typeof []` is
+    // 'object' so the prior typeof guard alone let attackers corrupt the JSONB.
+    let qa = body.qa;
+    if (qa === undefined || qa === null) {
+      qa = {};
+      for (const [k, v] of Object.entries(body)) {
+        if (typeof k === 'string' && k.startsWith('qa_') && k.length > 3) {
+          qa[k.slice(3)] = v;
+        }
+      }
+    }
+    if (!slug || !qa || typeof qa !== 'object' || Array.isArray(qa)) {
+      return res.status(400).json({ error: 'slug + qa required' });
+    }
+
     const b = await loadBySlug(slug);
     if (!b) return res.status(404).json({ error: 'business not found' });
-    // Strip empty answers so the JSONB stays clean.
+
+    // Strip empty answers so the JSONB stays clean. Coerce arrays-as-values to a
+    // single string so a malicious `qa_<id>=a&qa_<id>=b` repeat-key payload
+    // can't sneak in non-string types.
     const cleanQa = {};
     for (const [k, v] of Object.entries(qa)) {
-      const s = String(v || '').trim();
+      const raw = Array.isArray(v) ? v[v.length - 1] : v;
+      const s = String(raw || '').trim();
       if (s) cleanQa[k] = s.slice(0, 4000);
     }
+
     await query(
       `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
       [b.id, JSON.stringify(cleanQa), (recorded_by || '').slice(0, 120) || null]
     );
+
+    // Keep a rolling history per business so the per-shop row count can't grow
+    // unboundedly and slow down the latest-interview SELECT.
+    const HISTORY_MAX = 5;
+    await query(
+      `DELETE FROM business_interviews
+       WHERE business_id=$1
+         AND id NOT IN (
+           SELECT id FROM business_interviews
+           WHERE business_id=$1
+           ORDER BY recorded_at DESC NULLS LAST, id DESC
+           LIMIT $2
+         )`,
+      [b.id, HISTORY_MAX]
+    );
+
     log.info('interview saved', { slug, fields: Object.keys(cleanQa).length });
+
+    // No-JS form posts get a redirect back to the profile; JSON callers get JSON.
+    const wantsJson = (req.get('content-type') || '').includes('application/json') ||
+                      (req.get('accept') || '').includes('application/json');
+    if (!wantsJson) {
+      return res.redirect(`/biz/${encodeURIComponent(slug)}`);
+    }
     res.json({ ok: true, slug, fields: Object.keys(cleanQa).length });
   } catch (e) {
     log.err('interview save failed', { error: String(e.message || e) });

← 035afe2 yolo: iter 3 trivial patches from claude-codex debate  ·  back to Small Business Builder  ·  [morning-review] small-business-builder: gate interview admi 41c8a33 →