{"slug":"ventura-claw-leads","total":68,"limit":100,"offset":0,"since":null,"commits":[{"hash":"564a115","date":"2026-05-31 18:47:22 -0700","author":"Steve Abrams","subject":"Add root /favicon.{ico,svg} route (static is mounted under /static, so browser /favicon.ico 404'd)","body":""},{"hash":"61d2e0e","date":"2026-05-31 16:32:39 -0700","author":"Steve Abrams","subject":"Add per-site favicon (kills /favicon.ico 404)","body":""},{"hash":"34665fb","date":"2026-05-19 21:32:40 -0700","author":"Steve Abrams","subject":"neighborhood: add sort + density grid controls","body":"The /neighborhood/:slug page renders a primary business-grid but was\nmissing the sort <select> + density slider that /find already has.\nStanding rule — every site that renders a product/business grid MUST\nship both controls, persisted to localStorage.\n\nAdds a server-side sort whitelist (featured / newest / updated /\nname_asc / name_desc / vertical) mirroring /find's options exactly, and\na frontend JS block that hydrates the saved value from localStorage\nBEFORE the first grid load. The 'vcl-grid-sort' + 'vcl-grid-density'\nkeys are deliberately shared with /find so a user's preference carries\nacross both surfaces.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"7c04e29","date":"2026-05-19 21:30:57 -0700","author":"Steve Abrams","subject":"admin: add rel=noopener noreferrer on target=_blank links","body":"The two admin \"View your live profile\" links opened in a new tab without\nthe rel pair, leaking window.opener and Referer to the public profile\npage. Internal but still worth fixing — matches the rel pattern already\nused on the public business.ejs external links.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"350a516","date":"2026-05-19 21:30:12 -0700","author":"Steve Abrams","subject":"gitignore: exclude editor/snapshot backup files","body":"Adds *.bak, *.bak.*, *.pre-*, *.orig, *.rej, *.swp, *~ so accidental\nsnapshot files never land in commits.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"a6b54c3","date":"2026-05-07 17:57:49 -0700","author":"Steve","subject":"Fix legal-page ZIP in CAN-SPAM footer: 91335→91403","body":"Steve confirmed 2026-05-07. (.env MAILING_ADDRESS also corrected\nlocally but .env is gitignored so not in this commit.)\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"fbd4be4","date":"2026-05-07 17:50:23 -0700","author":"Steve","subject":"rel=alternate JSON + HTTP Link header on /find paginated routes","body":"New /api/find route mirrors /find query+filter+pagination, returns JSON.\nHTML head adds <link rel=\"alternate\" type=\"application/json\"> when\nalternateJsonUrl is in render context. /find route now sets HTTP Link\nheader with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).\n\nSmoke-tested locally:\n- GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}\n- HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header\n- HTML head emits 3 <link rel> tags including the new alternate\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"a182931","date":"2026-05-07 16:28:34 -0700","author":"Steve Abrams","subject":"Footer centered: 5-col equal grid + text-align center on every column","body":"Was 4-column 2fr/1fr/1fr/1fr (5 cols of markup, brand col stretched), all\nleft-aligned. Steve called out the asymmetry. Now 5 equal columns,\neach col is a flex column with align-items:center, max-width 1100px so\ncontent is centered without sprawling, fineprint stacks vertically\ncenter-aligned. Tablet breakpoint switched from repeat(2,1fr) to\nauto-fit minmax(160px,1fr) so the 5 cols collapse cleanly without an\nawkward 2+2+1 row."},{"hash":"d613a8f","date":"2026-05-07 16:25:49 -0700","author":"Steve Abrams","subject":"Right-rail visibility: brass-color + flex-shrink:0 on home rail 'See all N →' links","body":"Likely candidate for Steve's 'cannot see item on right' feedback. The\n4 per-vertical home rails (food/beauty/retail/creative) used class=\n'muted' (grey #5b5750 light / #9c958a dark) on the See-all links —\nlow contrast and easy to miss against an h2 brand heading on the\nsame flex row.\n\nThree fixes:\n- color:var(--brass) — pop against the body text\n- font-size 13→14 — slightly larger affordance\n- flex-shrink:0 + white-space:nowrap — keeps the link from wrapping\n  weirdly when h2 fills the row at narrow widths\n\nNow visible at all viewports across all 4 rails."},{"hash":"94e6ea5","date":"2026-05-07 15:49:58 -0700","author":"Steve Abrams","subject":"/find a11y: combobox pattern + aria-current on pagination + cleaner select labels","body":"Search input now implements the WAI-ARIA combobox pattern: role='combobox',\naria-controls='find-suggest', aria-autocomplete='list', aria-expanded\ntoggling on open/close, and aria-activedescendant tracking the active\nsuggestion as the user arrows through. Each <li> in the suggest list\nnow has a stable id + aria-selected. Screen readers will announce\neach suggestion as the user moves the focus marker.\n\nRemoved emoji-in-option-text from the Category select — '🍽 Food &\ndrink' reads as 'fork-and-knife Food and drink' on JAWS/NVDA. Plain\nlabels match what's spoken.\n\nPagination now has aria-current='page' on the 'Page N of M' indicator\nand explicit aria-label='Previous page' / 'Next page' on the arrow\nlinks so the rel=prev/next attribute isn't the only signal.\n\nSort/Category/City selects now use proper for=/id= label association\ninstead of just implicit wrapping (Safari + older AT prefers explicit)."},{"hash":"bdc2730","date":"2026-05-07 15:14:31 -0700","author":"Steve Abrams","subject":"Auto-fire codex-3way on every commit (hook + viewer)","body":"scripts/post-commit-hook.sh runs the 3-way debate in background after\nevery commit (with skips for trivial diffs and non-code paths). Hook\noutput lands at .git/codex-3way-hook/latest.summary.md as a symlink to\nthe latest run's summary, so the next yolo-loop tick can just read\nthat file and adjudicate findings without manually invoking the skill.\n\nThree tracked scripts:\n- post-commit-hook.sh : the actual hook logic (skip rules + bg fire)\n- install-hooks.sh   : per-clone setup (symlinks .git/hooks/post-commit)\n- last-debate.sh     : print the latest summary for ad-hoc inspection\n\nSkip rules:\n- only fires when .js/.ejs/.sql/.ts/.tsx/.json/.sh files changed\n- skips diffs <5 lines (avoids burning compute on doc tweaks)\n- self-recursion guard via CODEX_3WAY_HOOK_RUNNING env var\n\nNon-blocking: nohup + disown so commit returns immediately."},{"hash":"6ca9edb","date":"2026-05-07 15:05:52 -0700","author":"Steve Abrams","subject":"Geocode the final 38 stragglers via Nominatim — map now 100% covered","body":"scripts/geocode-nominatim.js hits OSM Nominatim (free, 1 req/sec policy,\nreal User-Agent) for the rows the corridor cross-reference couldn't\nmatch. First pass got 32 of 38; the 6 misses had unusual address\nsuffixes ('Office #20', 'Space #203', 'Front', '17047 1/2'). Tightened\nthe cleanStreet regex to strip those and got the last 6.\n\nMap markers: 1,811 → 1,849 (every active business is now pinned).\nZero corridor-seeded rows without lat/lng. Idempotent — only updates\nwhere latitude IS NULL.\n\nTook ~75 seconds total to geocode all 38 (rate-limited)."},{"hash":"ab2ce52","date":"2026-05-07 14:21:44 -0700","author":"Steve Abrams","subject":"/for-businesses: Find-your-business CTA + claim-flow narrative + search box","body":"The pricing page hadn't been updated since before the corridor seed.\nIt was framed as 'email us to get listed' — but with 1,819 listings\nalready seeded, most owners hitting the page are looking to claim,\nnot request a new entry.\n\nAdds: hero CTAs ('Find your business →' + 'See pricing'), an\nexplainer paragraph stating 1,800+ listings are pre-seeded with a\nfree claim button, and a 'Two ways to get on Ventura Claw' section\nthat splits the path: (1) claim existing, (2) email if missing.\nCloses with an inline search box that submits to /find — the\nshortest path from /for-businesses to the claim flow.\n\ncodex-3way (D+P+Pragmatist) round-1 reviewed the headline rewrite\n(prior commit 5a49679); 4 PROSECUTOR findings all false positives\n(city is enum-constrained by CITY_MAP, can never be null/empty/\nuser-controlled). DEFENDER SHIP verdict accepted."},{"hash":"5a49679","date":"2026-05-07 13:48:06 -0700","author":"Steve Abrams","subject":"Inject neighborhood into seeded headlines (170 distinct phrasings, was ~60)","body":"Templated headlines like 'Hair salon on Ventura Blvd.' (408 rows) and\n'Sit-down restaurant on Ventura Blvd.' (425 rows) now read 'Hair salon\nin Sherman Oaks.' / 'Sit-down restaurant in Encino.' — a single\ndeterministic UPDATE that rewrites both headline + description in\nplace. Distinct headline strings: ~60 → 170 (3x more variety, ~11\nrows per phrasing avg).\n\nPlus matching change to scripts/seed-corridor-all.js so future\nre-seeds bake the city in at insert time instead of needing a\npost-hoc UPDATE."},{"hash":"a8cee0a","date":"2026-05-07 13:12:49 -0700","author":"Steve Abrams","subject":"Tighten falsy-zero check in neighborhood.ejs total display","body":"Use 'typeof !== undefined' instead of truthiness so a legit 0\ndoesn't fall back to businesses.length. Cosmetic — both equal 0\nfor empty neighborhoods so no user-visible change. Caught by\ncodex-3way PROSECUTOR sweep on HEAD~5..HEAD."},{"hash":"199f582","date":"2026-05-07 13:06:54 -0700","author":"Steve Abrams","subject":"Fix 3 bugs in backfill-corridor-geo.js caught by codex-3way debate","body":"PROSECUTOR (kimi-k2.5) flagged these in round 1 of the 3-way debate;\nDEFENDER (qwen3:14b) said ship and PRAGMATIST (gemma3:12b) blocked on\na hallucinated SQL injection. After per-finding adjudication against\nthe actual code, three of PROSECUTOR's findings were genuine:\n\n1. streetKey('') returns '' which collapses every empty-addr row into\n   a single |city bucket. If both a VC row and a corridor row had\n   empty street, they'd match incorrectly. Now skipped on both index\n   and lookup sides.\n\n2. Catch handler called process.exit(1) without closing PG pools.\n   Could hang the process up to idleTimeoutMillis. Now best-effort\n   close before exit.\n\n3. (Marginally) the 244-row UPDATE loop has no transaction. Idempotent\n   on retry so deferred to a follow-up — not blocking ship.\n\nTwo of PROSECUTOR's findings were false positives (m.zip.slice was\nalready guarded; missing PGPASSWORD doesn't affect Mac trust auth)\nand got correctly knocked down by DEFENDER."},{"hash":"6e61684","date":"2026-05-07 12:32:58 -0700","author":"Steve Abrams","subject":"Backfill 206/244 missing lat/lng on corridor-seeded rows","body":"scripts/backfill-corridor-geo.js cross-references each VC row missing\nlat/lng against the ventura_corridor PG (15,551 corridor rows with\ncoordinates) using a normalized street+city key with name+zip fallback.\n244 - 38 = 206 rows recovered (84%); the 38 stragglers have street\naddresses that just don't appear in the corridor lat/lng index.\n\nMap marker count: 1,719 → 1,811 (+92, since some of the 206 weren't\nyet active or had been hidden in the dedupe pass; net visible +92).\nReversible — re-running the seed picks up corrected lat/lng from the\ncorridor source on next run."},{"hash":"fb3a3e4","date":"2026-05-07 12:30:34 -0700","author":"Steve Abrams","subject":"Home gets Organization+WebSite+FAQPage JSON-LD; neighborhood gets ItemList","body":"Three JSON-LD additions, all purely additive:\n\n1. home.ejs: @graph with Organization, WebSite (with potentialAction\n   SearchAction → /find?q={query} for sitelinks search box), and\n   FAQPage wrapping the existing 5 FAQ items. Validates as 3 graph\n   nodes with all 5 questions correctly mapped.\n\n2. neighborhood.ejs: ItemList JSON-LD added next to existing\n   BreadcrumbList. Same shape as /find — first 30 businesses\n   exposed as ListItem with rank+url+name, full count in\n   numberOfItems.\n\nOnce Google recrawls, brand searches for 'Ventura Claw' may surface\nsitelinks search box + FAQ snippets (taking 4-5x more SERP space)."},{"hash":"dbc5da9","date":"2026-05-07 12:28:11 -0700","author":"Steve Abrams","subject":"Cross-link neighborhood pages to vertical+city combo pages","body":"/neighborhood/<slug> now renders 8 vertical chips + an inline 'Jump to\na category' paragraph, each linking to the matching /find?vertical=X&\ncity=Y combo page with crawlable anchor text. 5 neighborhoods × 16\ncombo links = 80 new internal links pointing into the 37 combo pages\nseeded into sitemap.xml last tick — converts them from sitemap-only\norphans into part of the primary navigation graph.\n\nAlso fixed: lede was using businesses.length (capped at LIMIT 200) for\nthe count. Now uses the unfiltered totalCount from a separate aggregate\nquery. Sherman Oaks for example now reports 482 in its lede instead of\nthe capped 200."},{"hash":"d421ce8","date":"2026-05-07 11:55:25 -0700","author":"Steve Abrams","subject":"Add 37 vertical+city combo pages to sitemap; unclaimed banner on listing detail","body":"Two coordinated SEO + monetization improvements:\n\n1. sitemap.xml now lists 37 'vertical in city' combos (8v×5c minus 3\n   thin combos with <3 businesses) — '/find?vertical=beauty&city=\n   Sherman+Oaks' etc. The /find route already renders unique H1 +\n   meta + canonical for each combo (e.g. '155 beauty businesses on\n   Ventura Blvd in Sherman Oaks'), so these are real landing pages\n   from Google's perspective, not duplicates.\n\n2. /business/<slug> now shows a top-of-page 'unclaimed' banner with\n   anchor link to the existing #claim form for the 1,819 corridor-\n   seeded shells. The empty 'About' section now reads with context\n   ('listed from public records, owners can claim this page free')\n   instead of the generic 'this business hasn't added a description'.\n   Closes the gap between 'we have 1,819 unclaimed listings' and\n   'visitors can find the claim CTA above the fold on mobile'."},{"hash":"05ae6c7","date":"2026-05-07 11:22:52 -0700","author":"Steve Abrams","subject":"Add per-vertical sample rails to home page (food/beauty/retail/creative)","body":"Home was showing only category-chip counts plus the existing 'Featured'\nrail (claim+tier-gated, only 9 cards). With 1,819 corridor-seeded\nlistings, the home page should preview the directory's depth, not just\ngate-keep behind a search box.\n\nTop 4 verticals by count get a 6-card preview rail with 'See all N →'\nlink. Cards prioritize photographed/claimed/premier rows then random,\nso the rail rotates per request and rewards owners who fill out their\npage. Today's home: 28 unique business links vs. 9 before — a 3x lift\nin click targets without changing any other section.\n\nJSON-LD LocalBusiness + BreadcrumbList already exists on\n/business/<slug>; no structured-data work needed this tick."},{"hash":"5cc49d8","date":"2026-05-07 10:35:53 -0700","author":"Steve Abrams","subject":"Sitemap covers paginated pages; head supports rel=prev/next + filtered canonicals; map-CTA on home + find","body":"Three SEO improvements stacked together since they're a single user\njourney:\n\n1. sitemap.xml now lists /find?vertical=X&page=2..N for each paginated\n   vertical (beauty 4p, food 3p, retail 2p, creative 2p) — 7 new URLs\n   so Google crawls every business via the listing pages, not just the\n   1,819 direct /business/<slug>.\n\n2. head.ejs gains rel='prev'/rel='next' link tags + a canonicalPath\n   override. The /find route now passes the full filter+page query\n   string as canonical — /find?vertical=beauty&page=2 self-canonicalizes\n   instead of folding into /find.\n\n3. Home + /find both get a 'browse the map →' CTA. Map already accepted\n   ?vertical= filter; the /find→/map link preserves it so a user\n   filtering for fitness on the list view sees only fitness pins on the\n   map. 1,719 markers were unreachable from the home hero before this."},{"hash":"3f90e3f","date":"2026-05-07 10:02:31 -0700","author":"Steve Abrams","subject":"Add pagination to /find — page=N param + Prev/Next nav","body":"Beauty has 636 rows but the LIMIT 200 made everything past row 200\nunreachable. Add ?page=N (200/page, capped at 50) with stable id\ntiebreaker on ORDER BY so a row never appears on two pages. Pagination\nnav at the bottom of the grid uses rel='prev'/rel='next' for SEO.\n\nTop of file says 'Showing 200 of 636 businesses' on capped pages, plain\n'56 businesses found' when the page contains everything. Beauty=4\npages, food=3, retail=2, creative=2; the others fit on one page."},{"hash":"c64097d","date":"2026-05-07 10:00:56 -0700","author":"Steve Abrams","subject":"Per-vertical SEO meta uses real total + city names; visible count shows truth","body":"The find route was using businesses.length for both meta description and\nthe on-page count, but that's capped by LIMIT 200 — beauty's meta said\n'200 beauty businesses' when actual total is 636. Now compute the\nunfiltered total and top-3 cities once per request and use them in:\n- meta description: '636 beauty businesses on Ventura Blvd in Woodland\n  Hills, Sherman Oaks, Encino. ...'\n- visible page header: 'Showing 200 of 636 businesses' when capped,\n  '56 businesses found' when not.\n\nTwo extra COUNT/GROUP BY queries per /find hit, both indexed.\nDistinct meta per vertical lets each /find?vertical=X URL rank\nindependently for '<vertical> <city>' searches in Google."},{"hash":"1acea14","date":"2026-05-07 09:27:50 -0700","author":"Steve Abrams","subject":"Add scripts/dedupe-corridor-shells.sql + hide 119 noisy seed rows","body":"Hides booth-renter personal-name LLCs sharing addresses with proper\nstorefronts (52 rows) and the talent-agency production-shell LLCs that\nshare creative-vertical office suites (67 rows). Visible directory\nshrinks from 1,938 → 1,819 active rows. Reversible: status='hidden'\nnot deletion. Run script standalone after any future corridor reseed."},{"hash":"f62acb4","date":"2026-05-07 09:25:52 -0700","author":"Steve Abrams","subject":"add in-process 120s cache for /api/businesses.geo to prevent RSS spikes","body":""},{"hash":"602dc99","date":"2026-05-07 09:24:00 -0700","author":"Steve Abrams","subject":"increase max_memory_restart from 300M to 600M to stop OOM-triggered restart loop","body":"Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"},{"hash":"8485ef7","date":"2026-05-07 09:17:42 -0700","author":"Steve Abrams","subject":"Drop misleading 'verified listings' meta-description copy","body":"Home page meta-description claimed all listings were verified, but with\n1,938 corridor-seeded shells now in the directory most are unclaimed.\nReplace with a more accurate framing that signals owners can claim and\nupdate their own pages."},{"hash":"6572248","date":"2026-05-07 09:16:22 -0700","author":"Steve Abrams","subject":"Generalize corridor seed to all 8 verticals (1,938 directory shells)","body":"scripts/seed-corridor-all.js extends the food-vertical seed with\nper-vertical OSM-category + BTRC-NAICS-label maps:\n  beauty       669 (hairdresser/barber/nail/cosmetics)\n  retail       302 (clothing/jewelry/furniture/optical/florist/books)\n  creative     309 (photo/design/florist/promoter/sound/performing arts)\n  food         512 (already shipped)\n  cleaning      54 (janitorial/drycleaning/buildings)\n  fitness       52 (fitness centres + name-filtered amusement+rec)\n  pet_services  33 (pet care excl. veterinary)\n  auto_detail    7 (Car Washes label + name-filtered car_repair)\n\nHand-curated mapping deliberately excludes the green-list landmines\n(regulated medical/legal/financial/real-estate/contracting, mechanical\nauto, veterinary). Idempotent ON CONFLICT (slug) DO NOTHING."},{"hash":"50d32a1","date":"2026-05-07 09:09:12 -0700","author":"Steve Abrams","subject":"Seed food vertical from ventura_corridor public data (OSM + LA BTRC)","body":"scripts/seed-corridor-food.js pulls 1,062 corridor food rows from the\nventura_corridor PG (89 OSM POIs + 973 LA BTRC NAICS-classified rows),\nfilters out NAICS-mistagged rows ('Barber shops' fuzz-matched to\n'amenity: bar') and obvious owner-name registrations, normalizes city\ncasing, and inserts as tier='free' claim_status='unclaimed'\nsource='public_records' shells. /find?vertical=food now renders 512\nreal Ventura Blvd businesses across Sherman Oaks/Studio City/Encino/\nTarzana/Woodland Hills, up from 5 demo placeholders."},{"hash":"92a37d6","date":"2026-05-07 09:04:45 -0700","author":"Steve Abrams","subject":"feat(seo): image sitemap support for Google Image search","body":"- sitemap.xml now declares xmlns:image and emits <image:image> entries\n  inside each /business/:slug URL block when the business has a photo\n- image entries include <image:loc> (absolute URL) and <image:title>\n  (business name); falls back to no image entry when photo absent\n- skips og.png as image source — those are share cards, not real photos,\n  and would dilute Image-search relevance\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"d818054","date":"2026-05-07 08:34:08 -0700","author":"Steve Abrams","subject":"feat(seo): per-business dynamic Open Graph images","body":"- new lib/og-image.js builds 1200x630 SVG (vertical-keyed gradient + business\n  name + neighborhood + venturaclaw wordmark) and renders to PNG via sharp\n- new GET /business/:slug/og.png route (24h immutable cache, 404 on bad slug)\n- business page passes ogImage = photo_path absolute URL OR /og.png fallback\n- head.ejs already supports ogImage; business.ejs include block now resolves\n  the absolute URL at the route layer rather than in the template\n- 92KB output, 46/46 tests stable, deployed\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"c5e7571","date":"2026-05-07 08:05:52 -0700","author":"Steve Abrams","subject":"feat(admin): surface share-count on dashboard stat row","body":"Adds a 6th stat card (Shares · all-time) alongside Total/Last 30d/Delivered/\nPending/Unread/Profile views/Shares. Closes the loop on tick 30 so claimed\nbusiness owners see when their listing is being shared, not just messaged\nor viewed. Raw stat query extends with a single subselect on businesses.share_count.\nTests stable at 46/46.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"f5d2cd9","date":"2026-05-07 07:58:25 -0700","author":"Steve Abrams","subject":"feat(ux): hand-drawn SVG glyphs replace emoji on vertical hero gradients","body":"- new partial views/partials/vertical-glyph.ejs renders an inline line-art\n  SVG per vertical (food/beauty/retail/fitness/pet_services/auto_detail/\n  cleaning/creative). Uses currentColor so it picks up theme-aware tinting.\n- swapped 4 card-grid glyph sites (find/home recently-joined/home featured/\n  business similar-nearby/neighborhood) and the profile-page hero banner.\n- CSS .biz-card-glyph-svg sized via clamp() with drop-shadow + light-mode\n  override for dark glyphs on light gradients.\n- 46/46 tests stable; deployed to prod.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"a52f7bd","date":"2026-05-07 07:48:33 -0700","author":"Steve Abrams","subject":"feat(share): per-business share-count w/ session-dedup tracking","body":"- POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)\n- 30-min per-session dedup mirrors view-counter pattern\n- frontend share button fires fetch() after navigator.share or clipboard copy\n- profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)\n- DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0\n- migration applied to prod; 46/46 tests stable\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"826d347","date":"2026-05-07 03:55:41 -0700","author":"Steve Abrams","subject":"yolo tick 29: weekly-digest worker — Monday-morning summary email per claimed business","body":"scripts/weekly-digest.js:\n- Pulls claimed-with-email businesses\n- Skip when msgs_7d=0 AND views_7d=0 (nothing to summarize, don't spam silent listings)\n- assertSendCompliance pre-flight (CAN-SPAM gate fail-closed)\n- isSuppressed scrub per recipient\n- mintUnsubscribeToken + List-Unsubscribe headers (RFC 2369/8058)\n- complianceFooter with mailing address\n- Audit each send to comms_send_audit (decision='sent' / 'failed' /\n  reason='suppression')\n\nEmail body:\n  - 3-stat header row (msgs_7d / views_7d / unread, brass-italic)\n  - 'This week's new messages' table (top 5 names + emails)\n  - 'Open dashboard' CTA\n  - Live-profile link in muted footer\n  - Compliance footer + unsubscribe link\n\nDEFAULT MODE: --dry-run (no actual emails). Pass --live to send.\n\nCron NOT registered yet — Steve's call when to flip it on:\n  0 8 * * 1   /usr/bin/node /root/Projects/ventura-claw-leads/scripts/weekly-digest.js --live >> /var/log/vcl-weekly-digest.log 2>&1\n\nTested local --dry-run with 2 claimed businesses (1 active, 1 quiet):\n  · active → 'Your Ventura Claw week — 2 new messages, 13 profile views'\n  · quiet → silent skip\n  Output: sent=1 suppressed=0 skipped_no_activity=1 errors=0\n\n46/46 tests still pass."},{"hash":"91c32a2","date":"2026-05-07 03:11:50 -0700","author":"Steve Abrams","subject":"yolo tick 28: home 'Recently joined' rail (conditional ≥3 claimed)","body":"routes/public.js GET /: extra query — newest 6 businesses where\nclaim_status IN ('self','claimed') AND claimed_at IS NOT NULL, ORDER BY\nclaimed_at DESC. Pulls photo_path so the rail can show real photos\nwithout an extra query.\n\nviews/public/home.ejs: new section between FAQ and Featured. Renders only\nwhen ≥3 businesses are claimed (a 1-2 entry rail looks anemic on a\nhalf-empty home page; better to hide). Compact card layout — 240px\nminmax columns vs the standard 300px on /find. Same hero treatment as\nelsewhere (photo or gradient + glyph).\n\nDrives FOMO + creates social proof: 'businesses ARE actually onboarding'\nis a signal new visitors look for.\n\nVerified: with 4 claimed → rail renders with title + 6 cards. With 0\nclaimed → rail hidden entirely. 46/46 tests pass."},{"hash":"f40cfda","date":"2026-05-07 02:39:31 -0700","author":"Steve Abrams","subject":"yolo tick 27: 'More <vertical> nearby' rail at the bottom of every profile","body":"routes/public.js GET /business/:slug: extra query when the business has\nlat/lng — fetches 3 nearest same-vertical active businesses sorted by\nsquared cartesian distance on lat/lng. Skip the rail entirely when this\nbusiness has no coords (rare since seed-geocode runs on every business).\n\nSquared-cartesian is good enough for a 12-mile corridor — proper\nHaversine would give true great-circle distance but for a near-flat\nslice of LA the absolute error is < 0.01% per pair. Avoids needing the\nearthdistance/cube postgres extensions on prod.\n\nviews/public/business.ejs: new section after profile-body — H2 'More\n<vertical> nearby', sub-text, 3-card grid (mobile collapses to 1 col).\nCards reuse the same hero treatment as /find (photo or vertical\ngradient + emoji glyph).\n\npublic/css/public.css: .similar-nearby + .similar-grid + responsive\nbreakpoint at 768.\n\nLive: 'More food & drink nearby' renders on Olive & Salt with 3 nearby\nfood businesses. Real engagement boost — visitors who land on one\nprofile via search-engine traffic now have a within-vertical browse path\nwithout backtracking to /find. 46/46 tests still pass."},{"hash":"f41ef50","date":"2026-05-07 02:07:29 -0700","author":"Steve Abrams","subject":"yolo tick 26: per-neighborhood landing pages — /neighborhood/sherman-oaks etc.","body":"routes/public.js:\n- GET /neighborhood/:slug — whitelist of 5 valid slugs (sherman-oaks,\n  studio-city, encino, tarzana, woodland-hills); anything else 404s.\n  Match in DB by slugified neighborhood OR slugified city, so /neighborhood/\n  sherman-oaks catches both city='Sherman Oaks' and neighborhood='Sherman\n  Oaks' rows. Same ORDER BY as /find: tier-priority + claimed-first +\n  photo-having + alpha. 200-row LIMIT (matches /find).\n- Sitemap.xml: 5 new URLs added (one per neighborhood) at priority 0.7,\n  daily changefreq. Total now 50 URLs (was 45).\n\nviews/public/neighborhood.ejs: dedicated landing page —\n  - BreadcrumbList JSON-LD (Home › Find › <Neighborhood>) for rich-results\n  - <H1>Businesses in <Neighborhood></H1>\n  - 8 vertical-filter chips that pre-fill the neighborhood (clicking 'Food'\n    on /neighborhood/sherman-oaks goes to /find?vertical=food&city=Sherman+Oaks)\n  - Same business-card grid as /find (photo or gradient hero)\n  - Empty state for neighborhoods that haven't onboarded businesses yet\n\nviews/partials/footer.ejs: new 'Neighborhoods' column with 5 direct links\n— internal-link signal that helps Google's crawler reach the new pages.\n\nVerified live: 5 neighborhood URLs return 200, bogus slug 404s, sitemap\nshows 5 /neighborhood/* entries, footer renders the column. Real\nlocal-SEO landing-pages now exist for each segment of the corridor.\n46/46 tests still pass."},{"hash":"870584a","date":"2026-05-07 01:34:42 -0700","author":"Steve Abrams","subject":"yolo tick 25: /find auto-suggest dropdown — debounced AJAX, keyboard nav","body":"routes/public.js: GET /api/businesses/suggest?q=… returns top 8 active\nbusinesses matching name/headline/neighborhood/city via ILIKE wildcard.\nORDER BY: prefix matches first (LOWER(business_name) LIKE 'ol%'), then\ntier-priority (premier > standard > rest), then claimed > unclaimed,\nthen alphabetical. Cache-Control: no-store. Skips queries < 2 chars.\n\nviews/public/find.ejs: hidden <ul role=listbox> below the search input.\nJS:\n  - debounce 150ms on input event\n  - drop stale responses (currentQ guard prevents out-of-order races)\n  - render <li> with name + vertical chip + neighborhood line\n  - ArrowDown/Up/Enter/Escape keyboard nav (first item active by default)\n  - mousedown handler so click registers before input loses focus\n  - close on outside-click\n  - XSS-safe via escHtml() — no innerHTML of unescaped data\n\npublic/css/public.css: .find-suggest with shadow + scroll-cap at 360px,\n2-column grid (name left + vertical right + location row spans both).\n\nVerified live: /api/businesses/suggest?q=ol returns Olive & Salt Trattoria\nfirst (prefix match), 5 total. Markup present. 46/46 tests pass."},{"hash":"e1590f8","date":"2026-05-07 00:58:51 -0700","author":"Steve Abrams","subject":"yolo tick 24: home featured grid — photo-priority within tier + render hero per card","body":"routes/public.js GET /: featured query expanded to SELECT photo_path, plus\nnew ORDER BY tier within priority that prefers businesses-with-photos to\nbusinesses-without-photos within the same tier:\n\n  ORDER BY tier='premier' DESC, tier='standard' DESC,\n           (photo_path IS NOT NULL) DESC,\n           RANDOM()\n\nSo premier listings still always sort first; standard next; within each\ntier the rotation favors visually-richer cards that have a real photo\nuploaded. Pure positive incentive to upload (better placement) without\npenalizing photo-less listings (they still appear, just lower in the\nrandom pool).\n\nviews/public/home.ejs featured grid: same per-card hero treatment as /find\n— photo when present, vertical-themed gradient + emoji glyph as fallback.\nFeatured cards now visually match the directory's card pattern.\n\n46/46 tests still pass. The home page transforms once businesses upload\nphotos: the gradient placeholders give way to actual interior shots,\nstorefronts, etc."},{"hash":"c73de6a","date":"2026-05-07 00:26:48 -0700","author":"Steve Abrams","subject":"yolo tick 23: og:image + Twitter Card image + 'Share this listing' button","body":"OPEN GRAPH / TWITTER CARD\n- views/partials/head.ejs: optional ogImage param. When passed (and only\n  then), emits og:image + og:image:width + og:image:height + twitter:image\n  + twitter:card='summary_large_image' + twitter:title + twitter:description.\n  When ogImage absent, defaults to twitter:card='summary' (no large image).\n- views/public/business.ejs: passes ogImage=publicUrl+photo_path when the\n  business has a cover photo. Same path also added to LocalBusiness JSON-LD\n  image field for Google.\n- Net: a business with a photo now gets a properly attributed\n  social-share preview card on iMessage / Slack / Twitter / LinkedIn /\n  Facebook / Discord. Without a photo, falls back to the summary card.\n\nSHARE THIS LISTING\n- views/public/business.ejs: 'Share' button next to 'Send a message' /\n  'Visit website' / 'Call'. Uses navigator.share when available (Mobile\n  Safari, Chrome on Android), falls back to navigator.clipboard.writeText\n  with a '· copied' status flash.\n- Pure progressive enhancement — works without JS for users with\n  copy-link muscle memory.\n\n46/46 tests still pass. Verified on prod: og:image renders when photo set,\nsummary card otherwise; Share button present on every profile."},{"hash":"77c0ec3","date":"2026-05-06 23:54:40 -0700","author":"Steve Abrams","subject":"yolo tick 22: /admin/leads pagination — 50/page with prev/next chips","body":"Replaces the hardcoded LIMIT 200 with cursor-friendly offset pagination.\n50 rows/page, capped at 200 pages (10k rows = sane upper bound).\n\nroutes/admin.js: ?page= param (1-indexed, clamped 1..200). Fetches PER_PAGE+1\nrows so we can detect 'has next' without a separate COUNT query — leads.length\n> PER_PAGE means there's another page. Returns hasPrev/hasNext flags +\npage + perPage to the view.\n\nviews/admin/leads.ejs: pagination footer at the bottom of the table:\n  'Showing 51-100 of 247'  [← Prev] [Next →]\nURL helper preserves filter+range+q across pagination clicks (chip state\nalready preserved across pagination, pagination state already preserved\nacross chip clicks — the search form drops it intentionally since a new\nsearch resets to page 1).\n\n46/46 tests still pass. Combined freely with All/Unread + 7d/30d/90d/All\n+ ?q= search box — orthogonal axes."},{"hash":"eb723c7","date":"2026-05-06 23:22:32 -0700","author":"Steve Abrams","subject":"yolo tick 21: per-business profile-view counter (with bot+session+owner dedup)","body":"DB:\n- migrations/009_business_views.sql: business_views (business_id, day, n)\n  with PK on (business_id, day) so daily upsert is atomic. Index on\n  (business_id, day DESC) for the dashboard 30-day SUM.\n- POST-DEPLOY GRANT (vcl role on prod, table owned by postgres):\n    GRANT ALL ON business_views TO vcl;\n\nCounter logic in routes/public.js GET /business/:slug:\n  - Skip if user-agent matches /bot|crawler|spider|slurp|preview|fetch|\n    monitor|pingdom|uptime|headlesschrome/\n  - Skip if logged-in business owner viewing own profile\n  - Otherwise: 30-min per-session dedup via req.session.viewed_<bizId>,\n    then INSERT … ON CONFLICT (business_id, day) DO UPDATE SET n = n+1\n  - Fire-and-forget; render doesn't block on the counter write\n\nSurfaces:\n- Admin dashboard: new 'Profile views' stat card (last 30 days · today)\n  in the 5-card row alongside Total/30d/Delivered/Unread.\n- Public profile sidebar: when activity threshold met (≥3 msgs OR ≥25 views\n  in 30 days), brass-bordered side card shows BOTH stats stacked, divided\n  by a dotted line. Below threshold: nothing rendered (no '0 views' embarrassment).\n\nE2E verified all four scenarios:\n  fresh session     → n=1 ✓\n  re-hit same session → n=1 (dedup) ✓\n  Googlebot UA     → n=1 (bot skipped) ✓\n  another fresh session → n=2 ✓\n\n46/46 tests still pass."},{"hash":"2bd5453","date":"2026-05-06 22:48:41 -0700","author":"Steve Abrams","subject":"yolo tick 20: breadcrumb nav + BreadcrumbList JSON-LD on /business/:slug + admin/team last-active sort","body":"BREADCRUMB ON PROFILE\n- views/public/business.ejs: visible <nav class=breadcrumb> +\n  matching schema.org BreadcrumbList JSON-LD. 5 levels:\n    Home › Find a business › <Vertical> › <City> › <Business Name>\n  Each level (except current) is a clickable URL pointing at the right\n  filter on /find. The JSON-LD makes the breadcrumb eligible for\n  Google's rich-result breadcrumb chips above the SERP snippet.\n- 2 JSON-LD blocks now per profile (LocalBusiness + BreadcrumbList) —\n  validates as clean schema.org.\n\nADMIN/TEAM SORT\n- routes/admin.js GET /admin/team: ORDER BY changed from created_at ASC\n  to a 3-tier sort: self always first → last_login_at DESC NULLS LAST\n  → created_at ASC tiebreak. So the team page surfaces actively-using\n  teammates at the top, dormant ones below, never-signed-in at bottom.\n\n46/46 tests still pass. Verified live: 2 JSON-LD blocks render on prod\nwith valid Schema.org @types."},{"hash":"863a780","date":"2026-05-06 22:16:19 -0700","author":"Steve Abrams","subject":"yolo tick 19: sharp resize + WebP convert + EXIF strip on photo upload","body":"Tick 18 saved uploads at original dimensions and original format. This tick\nadds the post-processing pipeline:\n\nlib/photo-upload.js:\n- Multer cap raised 2MB → 5MB (sharp will compress down anyway)\n- After multer accepts the file (saved to .tmp), sharp:\n    .rotate()   — honor EXIF orientation BEFORE strip (otherwise iPhone\n                  portrait shots end up sideways)\n    .resize({ width:1600, height:1600, fit:'inside', withoutEnlargement:true })\n    .webp({ quality:85, effort:4 })\n    .withMetadata({ exif:undefined })  — strip EXIF for privacy + size\n    .toFile(<final>.webp)\n- Temp file unlinked after successful conversion. On sharp failure,\n  temp is cleaned and req._photoUploadError = 'image_decode_failed'.\n- Filename always ends in .webp regardless of input format.\n- req.file.path/filename/mimetype/size all rewritten so the route\n  handler stores the FINAL webp path in DB.\n\nviews/admin/profile.ejs:\n- copy updated (5 MB / auto-resize / WebP conversion explained)\n- new error message for image_decode_failed\n\nVerified locally with a 3000×2000 35KB JPG: output 1600×1067 3.7KB WebP\n— 89% size reduction. EXIF stripped (no metadata in output). 46/46 tests\nstill pass.\n\nSharp install on Kamatera: production npm install picks up the prebuilt\nlinux-x64 binding (libvips 8.x). No build tools needed."},{"hash":"65c0b91","date":"2026-05-06 21:42:44 -0700","author":"Steve Abrams","subject":"yolo tick 18: per-business cover photo upload (multer + 2MB cap + type whitelist)","body":"DB:\n- migrations/008_business_photo_path.sql: ADD COLUMN photo_path TEXT NULL.\n\nUpload pipeline:\n- lib/photo-upload.js: multer.diskStorage to public/uploads/business-photos\n  with timestamped per-business filenames (b-<userId>-<ts>.<ext>). 2 MB cap,\n  jpg/png/webp mime + extension whitelist. Errors stash on req._photoUploadError\n  so the route handler can show a typed message after CSRF passes.\n- server.js: multer middleware mounted on /admin/profile/photo BEFORE\n  csrfMiddleware (multipart fields including _csrf must be parsed first; the\n  earlier inline-multer attempt failed because csrfMiddleware ran before the\n  multipart body was parsed and req.body._csrf was empty).\n- routes/admin.js POST /admin/profile/photo: just reads req.file + writes\n  photo_path. Best-effort cleanup of the previous photo (path-bound to\n  PHOTO_DIR so a manipulated DB value can't escape that directory).\n- routes/admin.js POST /admin/profile/photo/delete: clear photo + unlink file.\n\nPublic surfaces:\n- views/admin/profile.ejs: 'Cover photo' section above the field form. Shows\n  current photo (200x120 thumb) + 'Remove photo' button. File input\n  accept='image/jpeg,image/png,image/webp' + Upload button.\n- views/public/business.ejs: when photo_path is set, render <img> in the\n  hero slot instead of the vertical-themed gradient. Fallback unchanged.\n- views/public/find.ejs: same swap on biz-card-hero. Routes/public.js GET /find\n  expanded to SELECT photo_path so cards can render it.\n- public/css/public.css: .vertical-hero-photo + .biz-card-hero-photo with\n  object-fit:cover for clean cropping at any aspect ratio.\n- server.js: + /uploads static mount with 30d cache.\n\nVerified e2e local: claim → upload 1x1 PNG → photo_path written, file saved\nto disk, /business/:slug renders <img src='/uploads/...'>, static GET → 200.\nMigration applied prod via sudo -u postgres. 46/46 tests pass."},{"hash":"8634cfd","date":"2026-05-06 21:04:31 -0700","author":"Steve Abrams","subject":"yolo tick 17: per-vertical SEO landing pages + home FAQ section","body":"PER-VERTICAL TITLE / H1 / META\n- routes/public.js GET /find: when ?vertical= or ?city= is set (or both),\n  build a custom <title>, meta description, and H1 specific to that filter.\n  Eight vertical-keyed landing pages now distinguish themselves to Google\n  ('Food & drink on Ventura Blvd' vs 'Beauty in Sherman Oaks on Ventura\n  Blvd' vs the generic 'Find a business — Ventura Claw'). Long-tail SEO\n  for every (vertical × neighborhood) combination — already in sitemap.xml\n  from tick 4, just now actually distinct content.\n- views/public/find.ejs: H1 swapped to <%= customH1 %>, route picks the right\n  string. Fallback for the unfiltered case = 'Search Ventura Blvd'.\n\nHOME FAQ\n- views/public/home.ejs: 5-question FAQ section between featured grid and\n  category browser, addresses the questions visitors actually have:\n    · How does this differ from Yelp/Google Maps?\n    · Why aren't doctors, lawyers, or contractors listed?\n    · How do leads get to the business?\n    · How much does it cost — for me, for the business?\n    · I own a business on Ventura Blvd. How do I claim my listing?\n- public/css/public.css: <details>/<summary> styling — brass + rotation\n  on the indicator, max-width on the answer copy, semantic markup so the\n  FAQ is searchable + accessible (collapse/expand via keyboard).\n\nVerified live: /find?vertical=food → 'Food & drink on Ventura Blvd' title\n+ H1; /find?vertical=beauty&city=Sherman+Oaks → 'Beauty in Sherman Oaks on\nVentura Blvd'. Home / shows FAQ. 46/46 tests still pass."},{"hash":"53d59c1","date":"2026-05-06 20:32:40 -0700","author":"Steve Abrams","subject":"yolo tick 16: profile activity badge (≥3 threshold) + /find empty-state with vertical pivot","body":"PROFILE ACTIVITY BADGE\n- routes/public.js GET /business/:slug: extra query for msgs_30d count.\n  Threshold = 3 to show — anything below stays silent (a fresh listing with\n  '0 messages received' is worse than no badge at all). Stat lives in a\n  brass-bordered side card above 'Quick info' on the profile sidebar.\n- Social proof for visitors weighing whether the business is reachable.\n\n/FIND EMPTY-STATE PIVOT\n- views/public/find.ejs: when filters yield zero results, replace the\n  bare 'no matches' line with the same 8-vertical chip grid the home page\n  uses. Pivots a dead-end search into 'browse by category' instead.\n- Reuses .verticals-grid + .vertical-chip CSS from the home — zero new\n  styles, just a reflow of the existing component into the empty state.\n\nVerified live: badge correctly suppressed at 0 msgs (Olive & Salt has 0,\nno badge rendered); will appear once a business crosses 3/month. Empty\nstate renders 8 chips when ?q=zzznoresults. 46/46 tests pass."},{"hash":"4e2663d","date":"2026-05-06 19:56:12 -0700","author":"Steve Abrams","subject":"yolo tick 15: home-page 'How it works' 3-step explainer","body":"views/public/home.ejs: section between hero and verticals — Find →\nMessage → Reply. Each step has brass-italic '01/02/03' label, serif\nheadline, and a 1-sentence body that names the actual product mechanic\n(5-min lead delivery, reply-to consumer, no middleman).\n\npublic/css/public.css: .how-it-works section + .how-grid (3 cols\ndesktop, 1 col phone via the existing 768px breakpoint). Steps inherit\nthe existing serif/sans + brass typography system; no new design tokens.\n\nConcrete visitor explanation right above the category browser, addresses\nthe most common 'wait, how does this work / will the business actually\nreply?' confusion. 46/46 tests still pass."},{"hash":"294e68a","date":"2026-05-06 19:53:46 -0700","author":"Steve Abrams","subject":"yolo tick 14: /admin/leads search-within-messages","body":"routes/admin.js GET /admin/leads now accepts ?q=… (lowercased ILIKE on\nconsumer_name / consumer_email / message / zip). Combines freely with\n?filter=unread + ?range=7|30|90|all — q is preserved across all the\nexisting chip clicks (rangeHref + filterHref helpers updated to round-trip).\n\nviews/admin/leads.ejs adds a search form between the title and the chip\nrow. Hidden inputs persist filter+range when the user submits a fresh\nsearch. 'Clear search' button appears when q is non-empty.\n\nUseful for active businesses needing to find a specific lead by name/email\nor recall a project mentioned in the message body. 200-row LIMIT preserved\nso search results stay snappy.\n\n46/46 tests still pass."},{"hash":"65ee0ec","date":"2026-05-06 19:51:52 -0700","author":"Steve Abrams","subject":"yolo tick 13: /map pin hover preview + /admin/billing recent activity feed","body":"/MAP HOVER PREVIEW\n- views/public/map.ejs: every Leaflet marker now has bindTooltip with the\n  business name + vertical kicker + neighborhood. Renders on hover (no\n  click needed). Tooltip themed to match the site (var(--bg)/--fg/--border)\n  with a subtle drop shadow.\n- Tooltip arrow stripped via .leaflet-tooltip.pin-hover::before {display:none}\n  for a cleaner look against light + dark themes.\n- popup (click) still works with the full action buttons.\n\n/ADMIN/BILLING ACTIVITY FEED\n- routes/admin.js: GET /admin/billing now also queries subscription_events\n  filtered by payload->>'metadata'->>'business_id' = current business id,\n  newest 20. JSON-path filter is the right approach since the same Stripe\n  account fires events to all sister projects (NPH, lawyer, etc.).\n- views/admin/billing.ejs: 'Recent activity' table above 'Manage\n  subscription'. Per-row: timestamp, event_type (mono), detail (amount in\n  USD if amount_paid/amount_due present, else status). Status='active'\n  renders green.\n\n46/46 tests still pass. /map and /admin/billing both 200/302 on prod."},{"hash":"abf7235","date":"2026-05-06 19:36:59 -0700","author":"Steve Abrams","subject":"yolo tick 12: 30-day lead-trend sparkline on admin dashboard","body":"Pure inline SVG, zero chart libraries, works whether the business has 1\nlead or 1,000. Bars scale linearly to peak day; peak day rendered in brass\nto make it pop. Empty days get a 1px baseline bar (faint --border color)\nso the time axis stays readable at zero.\n\nroutes/admin.js GET /admin: query expanded with a generate_series CTE that\nproduces a contiguous 30-day date axis (PT timezone), LEFT JOIN'd to\nbusiness_interest grouped by day. Returns trend = [{day, n}, ...] (length\n30 always) + trendPeak, no matter how sparse the data.\n\nviews/admin/dashboard.ejs: inline <svg viewBox=\"0 0 480 60\"> sparkline\nabove 'Recent leads' section. Each bar has a <title> tooltip with the\nday label + lead count. Footer shows date-range bookends. Header line:\n'peak X · total Y'.\n\nVerified live: e2e claim+seed-3-leads-on-different-days+fetch-dashboard\nshows the sparkline with the right bars at the right offsets. 46/46\ntests still pass."},{"hash":"2c5302b","date":"2026-05-06 19:27:24 -0700","author":"Steve Abrams","subject":"yolo tick 11: admin invite-teammate flow — multi-user per business","body":"DB:\n- migrations/007_business_invitations.sql: business_invitations table —\n  HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day\n  expires_at, consumed_at, ip_hash. Partial index on active.\n- POST-DEPLOY GRANT (always required when sudo -u postgres applies a\n  migration; vcl needs perms on the new table+sequence):\n    GRANT ALL ON business_invitations TO vcl;\n    GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;\n\nAuth lib:\n- lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed\n  alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).\n\nAdmin routes:\n- GET /admin/team: list active business_users + pending invitations,\n  flash messages for invited/removed/error states.\n- POST /admin/team/invite: validate email, idempotent on existing member,\n  mint token, send transactional email via Purelymail SMTP, audit in prod.\n- POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +\n  self-remove guards).\n\nPublic routes:\n- GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,\n  redirect to dashboard with welcome flash.\n\nViews: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.\n\nLive test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite\nPOST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46\ntests pass."},{"hash":"f3a4936","date":"2026-05-06 19:13:04 -0700","author":"Steve Abrams","subject":"yolo tick 10: home-page social proof + /admin/leads date-range filter","body":"HOME-PAGE SOCIAL PROOF\n- routes/public.js GET /: stats query now also pulls messages_30d (count of\n  business_interest rows in the last 30 days, scoped to active businesses\n  only via the join-free subquery) and claimed_count (how many active\n  businesses have been claimed by their owners).\n- views/public/home.ejs: hero-meta strip adds two more stat lines, but only\n  when their value is > 0 — a fresh deployment with zero messages doesn't\n  show 'X messages routed' (which would look bad). Pure social proof for\n  visitors weighing whether to message a business.\n\nLEADS DATE-RANGE FILTER\n- routes/admin.js GET /admin/leads: added ?range=7|30|90|all (whitelist\n  with explicit value coercion, avoids any SQL injection vector). Combines\n  freely with ?filter=unread. Counts query expanded to also produce\n  last_7 / last_30 / last_90 totals so the filter chips can show counts.\n- views/admin/leads.ejs: 4 new range chips (7d / 30d / 90d / All time)\n  separated from the All/Unread chips by a vertical divider. Both filter\n  axes preserved when switching the other (rangeHref/filterHref helpers\n  build the URL from current state).\n\n46/46 tests still pass. Both surfaces verified live on prod."},{"hash":"0d288c7","date":"2026-05-06 18:40:41 -0700","author":"Steve Abrams","subject":"yolo tick 9: lead read/unread tracking + dashboard unread count","body":"DB:\n- migrations/006_lead_read_state.sql: ADD COLUMN read_at TIMESTAMPTZ NULL on\n  business_interest. Partial index on read_at IS NULL for fast unread queries.\n\nRoutes:\n- GET /admin/leads now accepts ?filter=unread (whitelist; anything else = all).\n  ORDER changed to (read_at IS NOT NULL), created_at DESC so unread floats up.\n  Pulls counts {total, unread} for the filter chips.\n- POST /admin/leads/:id/read   → set read_at = COALESCE(read_at, now())\n- POST /admin/leads/:id/unread → set read_at = NULL\n- POST /admin/leads/mark-all-read → bulk-mark current business\n- Dashboard stats: + unread count, with red dot + Triage CTA when > 0\n\nUI:\n- views/admin/dashboard.ejs: 5th stat card 'Unread' — neutral when 0, red\n  bordered when > 0 with 'Triage →' link to /admin/leads?filter=unread.\n- views/admin/leads.ejs: All / Unread filter chips at top, brass dot in\n  leftmost column on unread rows, brass-tinted background on unread rows,\n  per-row 'Mark read' / 'Unread' button (POST + redirect-back), 'Mark all\n  read' button when unread count > 0, success-state 'Inbox zero 🎉' empty\n  state on the unread filter.\n\nMigration applied local + prod via sudo -u postgres (vcl role doesn't own\nthe table). 46/46 tests still pass."},{"hash":"1a8b9b4","date":"2026-05-06 18:17:07 -0700","author":"Steve Abrams","subject":"yolo tick 8: vertical-themed hero panels (no external image fetches)","body":"Every business now gets a CSS-gradient banner per vertical instead of plain\ninitials — visual upgrade across the directory grid + profile pages without\ncalling Wikimedia or Google Places (those are still v0.3 candidates).\n\nviews/public/business.ejs: 16/4 aspect-ratio .vertical-hero above the\nprofile-hero, with the vertical's emoji glyph centered.\n\nviews/public/find.ejs: 16/9 .biz-card-hero on top of every business card,\nextending to card edges via negative margin.\n\npublic/css/public.css: 8 vertical-keyed gradient classes (food/beauty/retail/\nfitness/pet_services/auto_detail/cleaning/creative), each with paired\nlight + dark mode palettes. Single-source emoji glyph from lib/verticals.js.\nPure CSS, no external images, no JS needed.\n\n46/46 tests still pass. Live on prod."},{"hash":"d4d9dd2","date":"2026-05-06 17:44:18 -0700","author":"Steve Abrams","subject":"yolo tick 7: bug fix — drop DEFAULT now() from business_interest.delivered_at","body":"The original 001_init.sql migration set delivered_at TIMESTAMPTZ DEFAULT now()\non business_interest. Surfaced when v0.2 #6 CSV export shipped: every newly\ncaptured lead showed Delivered=created_at even though lead-delivery cron\nhadn't actually run.\n\nLead-delivery cron is unaffected (gates on delivery_msg_id IS NULL, which is\nthe correct sentinel). But every read path (CSV export, admin inbox UI,\nbilling reconciliation) was misled.\n\nFix:\n  ALTER TABLE business_interest ALTER COLUMN delivered_at DROP DEFAULT;\n  UPDATE business_interest SET delivered_at = NULL\n   WHERE delivery_msg_id IS NULL AND delivered_at IS NOT NULL;\n\nVerified locally + applied on prod. Now: insert without delivered_at →\ndelivered_at = NULL until cron runs and writes it."},{"hash":"81d7dec","date":"2026-05-06 17:43:45 -0700","author":"Steve Abrams","subject":"yolo tick 6: /admin/leads.csv export + mobile audit clean","body":"CSV EXPORT\n- routes/admin.js: GET /admin/leads.csv — auth-gated (requireBusiness), pulls\n  up to 5000 rows for the current business, RFC 4180 quoting (CRLF,\n  '' escapes), Content-Disposition: attachment with date-stamped filename.\n  All fields included (name/email/phone/zip/source/message/delivered_at/\n  delivery_email/delivery_msg_id/bill_amount_cents) so businesses can\n  reconcile against their CRM.\n- views/admin/leads.ejs: 'Export CSV ↓' button under the lede.\n- Verified e2e: unauth → 302 to login. With session → 200 text/csv with\n  embedded-comma message correctly quoted.\n\nMOBILE AUDIT (qa-vcl-overflow.mjs in session-debrief skill)\n- Playwright headless at 380×720 + 768×720 across 10 routes.\n- Result: 0 real horizontal overflows. The flagged leaflet-tile elements\n  on /map are inside an overflow:hidden tile-pane (same false-positive\n  pattern as NPH; not actionable).\n- VCL was scaffolded mobile-first by lifting NPH's 1024 + 768 breakpoints\n  into public.css from day one — audit confirms no regression.\n\n46/46 tests still pass. CSV download verified on prod."},{"hash":"20a0eb6","date":"2026-05-06 17:41:30 -0700","author":"Steve Abrams","subject":"yolo tick 5: sort + density slider on /find (per global standing rule)","body":"CLAUDE.md updated 2026-05-06 with: 'Every site that renders a product grid\nMUST have sort + density slider'. VCL's /find renders a directory grid —\nadding compliance.\n\nSort whitelist (server-side ORDER BY clauses, anything else falls back to\n'featured'):\n  featured  — server's natural order: tier=premier→standard, claimed-first, name asc\n  newest    — created_at DESC\n  updated   — updated_at DESC\n  name_asc  — business_name ASC\n  name_desc — business_name DESC\n  vertical  — group by vertical, then name asc\n\nDensity: <input type=range min=220 max=480 step=20 default=300> drives\nthe --vcl-card-min CSS var on .business-grid (smaller=denser, larger=fewer-bigger).\nBoth sort + density persist in localStorage (vcl-grid-sort, vcl-grid-density).\nOn page load, if user has a saved non-default sort and the URL has none, we\nredirect to ?sort=… so server-side ordering matches.\n\nUI: grid-controls bar above the cards — left side keeps the count, right\nside has the sort + density. Brass-accent range thumb.\n\n46/46 tests still pass. All 6 sorts return 200 on prod."},{"hash":"713a5b4","date":"2026-05-06 17:39:50 -0700","author":"Steve Abrams","subject":"yolo tick 4: /robots.txt + /sitemap.xml — Google indexing infra","body":"robots.txt: User-agent: * + Allow: / + Disallow on admin/api/webhooks/\nunsubscribe/claim paths + Sitemap pointer.\n\nsitemap.xml: 45 URLs at v0.1 — 7 static (/, /find, /map, /for-businesses,\n/about, /privacy, /terms) + 8 vertical filter pages (/find?vertical=food etc.,\nuseful long-tail SEO landing pages) + every active business profile (lastmod\nfrom businesses.updated_at so Google revisits when listings change). Cap at\n50,000 URLs (Google limit).\n\nCache-Control: 1 hour. Generates on-demand from a single SQL query so it\nstays current without a cron.\n\nXML validates cleanly. Live test on prod 2026-05-06: /robots.txt → 200,\n/sitemap.xml → 200 with 45 valid <url> entries. 46/46 tests still pass."},{"hash":"e42956b","date":"2026-05-06 17:06:16 -0700","author":"Steve Abrams","subject":"yolo tick 3: 46-test npm test suite covering auth, compliance, stripe-lib, verticals, routes","body":"A safety net for night-long autonomous changes. Each ~30-min YOLO tick from\nhere on can run npm test before commit + before deploy and stop on any red.\n\ntests/auth.test.js (6 tests)\n- bcrypt password round-trip + null-hash safety\n- email shape validator (valid/invalid)\n- email normalization (case + whitespace)\n- claim token mint→consume→mark consumed→double-consume blocked\n- invalid token returns ok:false / reason:invalid\n- createUser ON CONFLICT idempotency\n\ntests/compliance.test.js (8 tests)\n- hasMailingAddress accepts real, rejects placeholder/empty\n- publicUrlOk: https public ok, localhost/127.0.0.1/empty rejected (note:\n  IS_PROD is module-load-frozen so we don't try to flip env at runtime)\n- addSuppression idempotent on (channel, identifier)\n- isSuppressed lookup + fail-closed on bad input\n- mintUnsubscribeToken→consumeUnsubscribeToken round-trip + idempotency\n- complianceFooter renders address + unsubscribe URL + campaign + visible link\n- listUnsubscribeHeader: RFC 2369 + RFC 8058 shape\n\ntests/stripe-lib.test.js (10 tests)\n- VCL_TIERS structure (3 tiers, all required fields)\n- isLive false when STRIPE_SECRET_KEY missing\n- priceIdFor / tierFromPriceId round-trip\n- eventBelongsToVCL filter: project metadata, item-level price match,\n  invoice line-level price match, foreign price rejected, malformed event\n  doesn't throw\n\ntests/verticals.test.js (6 tests)\n- VERTICALS has exactly 8 entries, all with key/label/blurb/icon\n- keys exactly match the green list (food/beauty/retail/fitness/pet_services/auto_detail/cleaning/creative)\n- NO regulated-vertical key (medical/legal/financial/real_estate/contracting/etc.)\n- BY_KEY index consistent\n- label/icon helpers\n\ntests/routes.test.js (16 tests)\n- 13 route smoke (200 / 404 expectations)\n- homepage + /find ItemList JSON-LD presence\n- /api/businesses.geo content-type + array shape\n- contact form rejects bad email shape (with real CSRF flow)\n\n46/46 pass in 902ms. PGHOST=/tmp required for socket auth on Mac dev."},{"hash":"9c3866d","date":"2026-05-06 17:01:45 -0700","author":"Steve Abrams","subject":"yolo tick 2: /unsubscribe handler — RFC 8058 one-click + landing page","body":"routes/public.js: GET /unsubscribe?t=TOKEN renders the landing page (idempotent\n— 2nd click is a no-op). POST /unsubscribe is the RFC 8058 one-click endpoint\nthat Gmail/Outlook hit automatically when the user taps the 'Unsubscribe' button.\n\nprocessUnsubscribe() consumes the token (already idempotent in lib/compliance:\nconsumed_at uses COALESCE) and writes to comms_suppression with reason=\n'unsubscribe' and source='unsubscribe_link'.\n\nserver.js: mounts a path-specific req.skipCsrf=true for /unsubscribe BEFORE\ncsrfMiddleware so RFC 8058 POSTs from mail clients (no browser session, no CSRF\ntoken) can land. The HMAC-derived token is the bearer auth.\n\nviews/public/unsubscribe.ejs: confirmation page showing identifier + campaign\ncontext. Per-reason error copy (invalid / no_token).\n\nTest 2026-05-06 local: token mint → GET landing → comms_suppression row\nwritten → second click idempotent (200, no error). One-click POST returns\n{ ok: true } with proper Content-Type. Cleanup verified."},{"hash":"520e445","date":"2026-05-06 17:00:03 -0700","author":"Steve Abrams","subject":"yolo tick 1: JSON-LD LocalBusiness + ItemList structured data","body":"views/public/business.ejs: emit LocalBusiness JSON-LD with name/url/description/\nphone/email/sameAs/address/geo. Always type=LocalBusiness (parent type) rather\nthan mapping to vertical-specific subtypes (Restaurant/BeautySalon/Pet…) since\nproper subtype mapping requires per-vertical schema.org alignment, while\nLocalBusiness is the safe parent for all 8 verticals.\n\nviews/public/find.ejs: emit ItemList JSON-LD on the directory list page —\ntop-30 businesses as ListItem entries with rank/url/name. Search engines surface\nthis as 'rich list' in results.\n\nLive on prod 2026-05-06: parsed cleanly, 30 items rendered for /find,\nLocalBusiness with geo+address for /business/:slug."},{"hash":"cc3cfef","date":"2026-05-06 16:51:52 -0700","author":"Steve Abrams","subject":"v0.2 #3: admin/claim flow + lead inbox + profile editor + billing UI","body":"CLAIM FLOW\n- POST /business/:slug/claim — visitor on unclaimed profile submits email,\n  we mint HMAC-signed token (24h TTL) + email magic link via Purelymail.\n- GET /claim/:token — magic link lands on a 'set a password' form.\n- POST /claim/:token — bcrypt-hashes password, creates business_users row,\n  flips businesses.claim_status='self', logs the user in.\n- claimLimiter: 5 req/hour/IP on the start endpoint.\n\nADMIN\n- /admin/login + /admin/logout (bcrypt verify, session-based, loginLimiter\n  10 req/15min).\n- /admin dashboard with stats (total leads, last 30d, delivered, pending) +\n  recent-5 leads + welcome flash on fresh claim.\n- /admin/profile editor — every public field (name/headline/description/\n  address/contact/socials).\n- /admin/leads inbox — full lead table with delivery status.\n- /admin/billing — pricing tiers from VCL_TIERS, upgrade/downgrade buttons\n  hit /admin/billing/checkout (Stripe Checkout) + manage-subscription button\n  hits /admin/billing/portal (Stripe Customer Portal). Real flow with the\n  live keys from v0.2 #4.\n\nINFRA\n- lib/auth.js — bcrypt + claim-token mint/consume + attachBusiness +\n  requireBusiness; res.locals.currentBusinessUser + currentBusiness exposed.\n- lib/csrf.js — lifted from NPH; res.locals.csrfToken auto-set; safe-method\n  pass-through; webhooks bypass; double-submit + timing-safe compare.\n- 4 new routes mounted: auth, claim, admin (auth.requireBusiness gated).\n- 6 new EJS: claim-result, claim-complete, admin/login, admin/dashboard,\n  admin/profile, admin/leads, admin/billing.\n- header partial: 'Business sign in' / 'Dashboard' link toggle.\n- business.ejs unclaimed branch: inline claim form with email field.\n\nDB\n- migrations/004_business_users_and_claims.sql — business_users (FK to\n  businesses, UNIQUE on email + (business_id,email)) and claim_tokens\n  (token UNIQUE, FK to businesses, expires_at, consumed_at, ip_hash).\n\nLive test 2026-05-06 on prod (cactus-and-cane-plants-tarzana):\n  GET profile (csrf) → POST /business/:slug/claim (email submitted) →\n  email-fetch token from DB → GET /claim/token (form) → POST /claim/token\n  (password set, password_confirm matched) → claim_status flipped to\n  'self' with claimed_by_email recorded → session-login automatic → all\n  4 admin pages return 200 (dashboard / leads / profile / billing).\n\nCloses v0.2 #3. Now the Stripe products from v0.2 #4 have a UI to be\npurchased through; admin/billing/checkout invokes\nstripe.createSubscriptionCheckout with the right price ID per tier."},{"hash":"319e023","date":"2026-05-06 16:42:18 -0700","author":"Steve Abrams","subject":"v0.2 #4: Stripe subscription billing — products, prices, webhook live","body":"Same Stripe LIVE account as NPH + lawyer + 3 home-history sites; VCL filters\nincoming webhook events by metadata.project so cross-project event leakage\nis impossible.\n\nWHAT LANDED\n- scripts/stripe-bootstrap-v01.js: idempotent bootstrap. Creates 3 products\n  (Starter $49/mo, Standard $99/mo, Premier $199/mo) keyed by metadata.vcl_tier.\n  Creates webhook endpoint at https://leads.venturaclaw.com/webhooks/stripe with\n  8 subscription events (no payment_intent — VCL has no consumer payment surface).\n- lib/stripe.js: VCL_TIERS + createSubscriptionCheckout + createPortalSession +\n  constructWebhookEvent + eventBelongsToVCL (the filter that drops events from\n  other projects sharing this Stripe account).\n- routes/webhooks.js: /webhooks/stripe POST handler. Sig validation, eventBelongsToVCL\n  check, idempotency lock via subscription_events.stripe_event_id UNIQUE, on\n  customer.subscription.* updates businesses.tier + subscription_status +\n  stripe_subscription_id.\n- db/migrations/003_subscription_events.sql: audit + idempotency table.\n- server.js: mount /webhooks/stripe with raw-body parser BEFORE express.json so\n  signatures verify; CSP allows js.stripe.com + api.stripe.com + hooks.stripe.com.\n- 4 secrets saved to /secrets master + routed VCL-only:\n    STRIPE_VCL_WEBHOOK_SECRET (whsec)\n    STRIPE_PRICE_VCL_STARTER_MONTH  / STANDARD_MONTH / PREMIER_MONTH\n- VCL added to STRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEY destinations; live keys\n  now land on VCL Mac2 + Kamatera.\n\nWHAT'S STILL MISSING (v0.2 #3 admin/claim)\n- No UI to actually buy a subscription yet. createSubscriptionCheckout exists\n  but no business_users / claim flow / /admin route to invoke it. That's the\n  next-session task — once a business can sign in, /admin/billing/checkout can\n  call into this.\n\nLIVE on prod 2026-05-06: webhook endpoint we_1TUF1Y63uNmiRsMbNr1kLu52 enabled\nwith 8 events; HEAD/GET=200, POST(unsigned)=400. isLive=true on both hosts.\npriceIdFor() returns valid price IDs for all 3 tiers. Webhook secret loaded."},{"hash":"a6b4e19","date":"2026-05-06 16:36:45 -0700","author":"Steve Abrams","subject":"v0.2 #1+#2: lead-delivery cron + /map Leaflet view","body":"#1 LEAD-DELIVERY CRON\n- scripts/deliver-leads.js: scans business_interest WHERE delivery_msg_id IS NULL,\n  routes each new lead to the business email via Purelymail SMTP with\n  reply-to=consumer_email so the business can reply directly without VCL on\n  the chain. Per-tier billing field reserved (all tiers $0/lead in v0.1; v0.2 #4\n  flips paid tiers to metered values via Stripe subscription).\n- lib/email.js: lifted from NPH, rebranded to info@venturaclaw.com SMTP, added\n  replyTo support for the lead-delivery use case.\n- lib/compliance.js: lifted from NPH; renamed installer_id → business_id\n  throughout (function args, DB column refs, footer brand).\n- db/migrations/002_comms_suppression.sql: comms_suppression /\n  comms_send_audit / unsubscribe_tokens, business_id FK to businesses.\n- routes.json: INFO_VENTURACLAW_COM_PASSWORD now fans to ventura-claw-leads\n  (Mac2 + Kamatera) + the existing ventura-claw app. Kamatera prod has the\n  Purelymail password loaded.\n- Cron */5 * * * * on Kamatera root crontab → /var/log/vcl-deliver-leads.log.\n\nLive test 2026-05-06 on prod: lead delivered (msgId @venturaclaw.com),\nqueue row updated with delivery_email + msg_id + bill_amount_cents,\ncomms_send_audit row 'sent' / 'lead_delivery'. Cron registered.\n\n#2 /MAP LEAFLET VIEW\n- routes/public.js: GET /map (renders view + stats), GET /api/businesses.geo\n  (JSON with lat/lng/tier/vertical for client-side rendering).\n- views/public/map.ejs: lifted NPH pattern. MarkerCluster, OSM tiles, tier-\n  colored pins (free=gray, starter=sky, standard=indigo, premier=brass),\n  popup with vertical tag + headline + 'View profile' / 'Message' CTAs.\n  Search box + vertical select + 'View as list' cross-link to /find with\n  preserved query params.\n- public/vendor/leaflet/ + public/vendor/markercluster/: lifted from NPH.\n- scripts/geocode-seed.js: assigns deterministic per-slug jitter (~±0.55mi)\n  around 5 Ventura Blvd neighborhood centroids. v0.2 #5 will replace\n  jittered coords with real Google Places lat/lng on real-business ingest.\n- views/partials/header.ejs: added Map nav link.\n\n30 of 30 seed rows pinned on https://leads.venturaclaw.com/map."},{"hash":"77e482c","date":"2026-05-06 16:20:28 -0700","author":"Steve Abrams","subject":"v0.1: ventura-claw-leads scaffold — directory + lead-routing for unregulated Ventura Blvd small businesses","body":"A sister project to NPH that lifts the directory + lead-capture primitives\nbut explicitly excludes regulated industries (medical, legal, financial,\nreal-estate, contracting). 8-vertical green list enforced at the DB level\nvia CHECK constraint:\n\n  food · beauty · retail · fitness · pet_services · auto_detail · cleaning · creative\n\nWhat's in v0.1:\n  - Express server on :9789, pm2 ventura-claw-leads\n  - Postgres ventura_claw_leads db, schema with businesses + business_interest +\n    session tables. updated_at trigger; partial indexes on hot paths.\n  - 8 public routes: / /find /business/:slug /business/:slug/contact (POST)\n    /for-businesses /about /privacy /terms /healthz; 404 + error handlers.\n  - EJS template stack: head + header + footer partials, home + find +\n    business profile + contact-result + for-businesses + about + legal +\n    404 + error pages. Cormorant Garamond + Inter, brass accent, dark/light\n    toggle, 1024 + 768 breakpoints from day one.\n  - 30 hand-picked demo businesses across 6 Ventura Blvd neighborhoods\n    (Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills) covering\n    all 8 verticals.\n  - Subscription model in DB (free/starter/standard/premier tiers); no\n    consumer money flow — businesses pay subscription, leads are forwarded\n    free of charge to the visitor.\n  - Compliance footer + ToS + Privacy positioning as 'directory and\n    advertising service' (not 'lead-coordination' which has different\n    legal weight). Explicit disclaimer about excluding licensed\n    professionals.\n  - Full e2e tested: 11 routes return correct status codes, contact form\n    POST writes to business_interest with the business join verified.\n\nWhat's NOT in v0.1 (saved for v0.2):\n  - Stripe subscription billing (skeletons in .env.example, no UI)\n  - Admin dashboard for businesses (claim flow, profile edit, lead inbox)\n  - Lead-delivery email cron (the v0.7 NPH notify-queue pattern, scoped\n    here to forward each new business_interest row to the business email)\n  - /map view (will lift NPH's Leaflet impl)\n  - Public-records ingestion (LA County DBA + GoogleMyBusiness scrape)\n"}]}