[object Object]

← back to Ventura Claw Leads

yolo tick 6: /admin/leads.csv export + mobile audit clean

81d7dec43d64633e5fcc945e807c9a477ad84ca1 · 2026-05-06 17:43:45 -0700 · Steve Abrams

CSV EXPORT
- routes/admin.js: GET /admin/leads.csv — auth-gated (requireBusiness), pulls
  up to 5000 rows for the current business, RFC 4180 quoting (CRLF,
  '' escapes), Content-Disposition: attachment with date-stamped filename.
  All fields included (name/email/phone/zip/source/message/delivered_at/
  delivery_email/delivery_msg_id/bill_amount_cents) so businesses can
  reconcile against their CRM.
- views/admin/leads.ejs: 'Export CSV ↓' button under the lede.
- Verified e2e: unauth → 302 to login. With session → 200 text/csv with
  embedded-comma message correctly quoted.

MOBILE AUDIT (qa-vcl-overflow.mjs in session-debrief skill)
- Playwright headless at 380×720 + 768×720 across 10 routes.
- Result: 0 real horizontal overflows. The flagged leaflet-tile elements
  on /map are inside an overflow:hidden tile-pane (same false-positive
  pattern as NPH; not actionable).
- VCL was scaffolded mobile-first by lifting NPH's 1024 + 768 breakpoints
  into public.css from day one — audit confirms no regression.

46/46 tests still pass. CSV download verified on prod.

Files touched

Diff

commit 81d7dec43d64633e5fcc945e807c9a477ad84ca1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 17:43:45 2026 -0700

    yolo tick 6: /admin/leads.csv export + mobile audit clean
    
    CSV EXPORT
    - routes/admin.js: GET /admin/leads.csv — auth-gated (requireBusiness), pulls
      up to 5000 rows for the current business, RFC 4180 quoting (CRLF,
      '' escapes), Content-Disposition: attachment with date-stamped filename.
      All fields included (name/email/phone/zip/source/message/delivered_at/
      delivery_email/delivery_msg_id/bill_amount_cents) so businesses can
      reconcile against their CRM.
    - views/admin/leads.ejs: 'Export CSV ↓' button under the lede.
    - Verified e2e: unauth → 302 to login. With session → 200 text/csv with
      embedded-comma message correctly quoted.
    
    MOBILE AUDIT (qa-vcl-overflow.mjs in session-debrief skill)
    - Playwright headless at 380×720 + 768×720 across 10 routes.
    - Result: 0 real horizontal overflows. The flagged leaflet-tile elements
      on /map are inside an overflow:hidden tile-pane (same false-positive
      pattern as NPH; not actionable).
    - VCL was scaffolded mobile-first by lifting NPH's 1024 + 768 breakpoints
      into public.css from day one — audit confirms no regression.
    
    46/46 tests still pass. CSV download verified on prod.
---
 routes/admin.js       | 34 ++++++++++++++++++++++++++++++++++
 views/admin/leads.ejs |  1 +
 2 files changed, 35 insertions(+)

diff --git a/routes/admin.js b/routes/admin.js
index f9399fd..465a3d5 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -92,6 +92,40 @@ router.get('/leads', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// Lead inbox CSV export. Useful when a business runs reporting in Excel /
+// Sheets, or when paid-tier subscribers reconcile leads against their CRM.
+// All fields included — name, email, phone, ZIP, message, timestamps, delivery
+// state, billing amount. RFC 4180 quoting (CRLF, "" escapes inner quotes).
+router.get('/leads.csv', async (req, res, next) => {
+  try {
+    const bizId = res.locals.currentBusiness.id;
+    const leads = await db.many(`
+      SELECT id, consumer_name, consumer_email, consumer_phone, message, zip, source,
+             created_at, delivered_at, delivery_email, delivery_msg_id, bill_amount_cents
+        FROM business_interest
+       WHERE business_id = $1
+       ORDER BY created_at DESC
+       LIMIT 5000
+    `, [bizId]);
+    function q(v) {
+      if (v == null) return '';
+      const s = (v instanceof Date) ? v.toISOString() : String(v);
+      if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
+      return s;
+    }
+    const headers = ['id','created_at','consumer_name','consumer_email','consumer_phone','zip','source','message','delivered_at','delivery_email','delivery_msg_id','bill_amount_cents'];
+    let csv = headers.join(',') + '\r\n';
+    for (const l of leads) {
+      csv += headers.map(h => q(l[h])).join(',') + '\r\n';
+    }
+    const dateStamp = new Date().toISOString().slice(0, 10);
+    res.set('Content-Type', 'text/csv; charset=utf-8');
+    res.set('Content-Disposition', `attachment; filename="vcl-leads-${dateStamp}.csv"`);
+    res.set('Cache-Control', 'no-store');
+    res.send(csv);
+  } catch (err) { next(err); }
+});
+
 // ─── Billing ───────────────────────────────────────────────────────────
 router.get('/billing', (req, res) => {
   res.render('admin/billing', {
diff --git a/views/admin/leads.ejs b/views/admin/leads.ejs
index bdcd2be..a495f06 100644
--- a/views/admin/leads.ejs
+++ b/views/admin/leads.ejs
@@ -5,6 +5,7 @@
   <p class="kicker">Leads</p>
   <h1 class="display-sm">Inbox</h1>
   <p class="lede">Every customer message routed to <%= currentBusiness.business_name %>. Replies via your email client go straight to the customer.</p>
+  <p style="margin-top:8px"><a href="/admin/leads.csv" class="btn btn-ghost btn-sm" download>Export CSV ↓</a></p>
 
   <% if (leads.length === 0) { %>
     <p class="muted" style="margin-top:32px">No leads yet. Make sure your <a href="/admin/profile">profile</a> is filled out — the more complete the listing, the more inbound.</p>

← 20a0eb6 yolo tick 5: sort + density slider on /find (per global stan  ·  back to Ventura Claw Leads  ·  yolo tick 7: bug fix — drop DEFAULT now() from business_inte d4d9dd2 →