[object Object]

← back to Ventura Claw Leads

feat(share): per-business share-count w/ session-dedup tracking

a52f7bd29bdaccdd21a6d28aa2dfead3de7306f7 · 2026-05-07 07:48:33 -0700 · Steve Abrams

- POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)
- 30-min per-session dedup mirrors view-counter pattern
- frontend share button fires fetch() after navigator.share or clipboard copy
- profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)
- DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0
- migration applied to prod; 46/46 tests stable

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

Files touched

Diff

commit a52f7bd29bdaccdd21a6d28aa2dfead3de7306f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 07:48:33 2026 -0700

    feat(share): per-business share-count w/ session-dedup tracking
    
    - POST /api/businesses/:slug/share-track (CSRF-bypassed at server mount)
    - 30-min per-session dedup mirrors view-counter pattern
    - frontend share button fires fetch() after navigator.share or clipboard copy
    - profile sidebar shows 'shared X times' once count >= 10 (anti-zero guard)
    - DB migration 010 adds businesses.share_count INT NOT NULL DEFAULT 0
    - migration applied to prod; 46/46 tests stable
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 db/migrations/010_share_count.sql |  6 ++++++
 routes/public.js                  | 24 ++++++++++++++++++++++++
 server.js                         |  4 ++++
 views/public/business.ejs         | 16 ++++++++++++++--
 4 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/db/migrations/010_share_count.sql b/db/migrations/010_share_count.sql
new file mode 100644
index 0000000..4b44ecd
--- /dev/null
+++ b/db/migrations/010_share_count.sql
@@ -0,0 +1,6 @@
+-- Per-business share counter. Increments when a visitor taps the 'Share'
+-- button on /business/:slug. Surfaced on profile as social proof at the
+-- ≥10 threshold (below that we don't show — same anti-pattern guard as
+-- views and messages).
+
+ALTER TABLE businesses ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0;
diff --git a/routes/public.js b/routes/public.js
index 1c684b1..f2c951e 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -307,6 +307,30 @@ router.get('/map', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// Share-track ping. Frontend fires after a successful navigator.share or
+// clipboard copy. CSRF is bypassed at the server-mount level (see server.js).
+// Per-session dedup mirrors the view-counter pattern — same browser hitting
+// the share button repeatedly counts once per 30 minutes.
+router.post('/api/businesses/:slug/share-track', async (req, res, next) => {
+  try {
+    const slug = String(req.params.slug || '').slice(0, 200);
+    const biz = await db.one(`SELECT id FROM businesses WHERE slug = $1 AND status = 'active'`, [slug]);
+    if (!biz) return res.json({ ok: false, reason: 'not_found' });
+
+    if (req.session) {
+      const seenKey = 'shared_' + biz.id;
+      const lastSeen = req.session[seenKey];
+      const now = Date.now();
+      if (lastSeen && (now - lastSeen) <= 30 * 60 * 1000) {
+        return res.json({ ok: true, dedup: true });
+      }
+      req.session[seenKey] = now;
+    }
+    await db.query(`UPDATE businesses SET share_count = share_count + 1 WHERE id = $1`, [biz.id]);
+    res.json({ ok: true });
+  } catch (err) { next(err); }
+});
+
 router.get('/api/businesses/suggest', async (req, res, next) => {
   try {
     const q = String(req.query.q || '').trim().toLowerCase().slice(0, 60);
diff --git a/server.js b/server.js
index bda4dc1..45600ab 100644
--- a/server.js
+++ b/server.js
@@ -83,6 +83,10 @@ app.use(auth.attachBusiness);
 // RFC 8058 one-click unsubscribe — email clients POST without browser session.
 // The HMAC-derived token IS bearer auth; bypass CSRF.
 app.use('/unsubscribe', (req, res, next) => { req.skipCsrf = true; next(); });
+// Share-track: fire-and-forget telemetry POST from the share button on
+// /business/:slug. Public + idempotent (per-session dedup), no PII, low
+// abuse value. Bypass CSRF so the JS doesn't have to plumb the token.
+app.use('/api/businesses/:slug/share-track', (req, res, next) => { req.skipCsrf = true; next(); });
 // Photo upload is multipart/form-data — multer must parse it BEFORE
 // csrfMiddleware so req.body._csrf is populated for the timing-safe compare.
 // The actual route handler (in routes/admin.js) reads req.file directly.
diff --git a/views/public/business.ejs b/views/public/business.ejs
index 10dab53..d3cdaff 100644
--- a/views/public/business.ejs
+++ b/views/public/business.ejs
@@ -105,6 +105,11 @@
 </section>
 <script>
   (function(){
+    var slug = '<%= business.slug %>';
+    function trackShare() {
+      // Fire-and-forget — never block the UX on the counter ping.
+      try { fetch('/api/businesses/' + encodeURIComponent(slug) + '/share-track', { method: 'POST' }); } catch(e) {}
+    }
     document.querySelectorAll('[data-share-url]').forEach(function(btn){
       btn.addEventListener('click', async function(){
         var url = btn.getAttribute('data-share-url');
@@ -112,11 +117,12 @@
         var status = btn.querySelector('[data-share-status]');
         // Web Share API on mobile/Safari, clipboard fallback elsewhere.
         if (navigator.share) {
-          try { await navigator.share({ title: title, url: url }); return; }
+          try { await navigator.share({ title: title, url: url }); trackShare(); return; }
           catch(e){ /* user cancelled — fall through to clipboard */ }
         }
         try {
           await navigator.clipboard.writeText(url);
+          trackShare();
           if (status) { status.textContent = '· copied'; setTimeout(function(){ status.textContent=''; }, 2000); }
         } catch(e) {
           if (status) { status.textContent = '· press ⌘C'; }
@@ -164,11 +170,17 @@
           </div>
         <% } %>
         <% if (activity.views_30d >= 25) { %>
-          <div style="text-align:center">
+          <div style="text-align:center;<%= (business.share_count || 0) >= 10 ? 'border-bottom:1px dotted var(--border);padding-bottom:12px;margin-bottom:12px' : '' %>">
             <p style="font-family:var(--serif);font-size:32px;color:var(--brass);margin:0;line-height:1"><%= activity.views_30d %></p>
             <p style="margin:6px 0 0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--fg-muted)">profile views · last 30 days</p>
           </div>
         <% } %>
+        <% if ((business.share_count || 0) >= 10) { %>
+          <div style="text-align:center">
+            <p style="font-family:var(--serif);font-size:32px;color:var(--brass);margin:0;line-height:1"><%= business.share_count %></p>
+            <p style="margin:6px 0 0;font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--fg-muted)">times shared</p>
+          </div>
+        <% } %>
       </div>
     <% } %>
     <div class="side-card">

← 826d347 yolo tick 29: weekly-digest worker — Monday-morning summary  ·  back to Ventura Claw Leads  ·  feat(ux): hand-drawn SVG glyphs replace emoji on vertical he f5d2cd9 →