[object Object]

← back to Butlr

snapshot: backup uncommitted work (4 files)

96ffa092d0e6e5c289c7c4c6a23ed7501c575f79 · 2026-05-13 12:14:26 -0700 · SteveStudio2

Files touched

Diff

commit 96ffa092d0e6e5c289c7c4c6a23ed7501c575f79
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 12:14:26 2026 -0700

    snapshot: backup uncommitted work (4 files)
---
 routes/places.js                   | 102 ++++++++++++++++++++++++++++
 scripts/status-snapshot.sh         | 125 +++++++++++++++++++++++++++++++++++
 server.js                          |   2 +
 views/public/wizard-2-business.ejs | 132 ++++++++++++++++++++++++++++++++++++-
 4 files changed, 360 insertions(+), 1 deletion(-)

diff --git a/routes/places.js b/routes/places.js
new file mode 100644
index 0000000..a0c9d2f
--- /dev/null
+++ b/routes/places.js
@@ -0,0 +1,102 @@
+// Places search — OpenStreetMap Nominatim (free, no API key).
+//
+// Gated on location: caller must provide either lat+lng OR a ZIP code so
+// results are biased to the user's actual area. Without location we return
+// 400 — searching globally for "AAA" returns garbage and burns Nominatim's
+// 1-req/sec quota for everyone else.
+//
+// Nominatim ToS: identify yourself in User-Agent, max 1 req/sec, no heavy use.
+// We respect via a simple in-memory rate limiter.
+
+const express = require('express');
+const router = express.Router();
+
+const NOMINATIM_BASE = process.env.NOMINATIM_BASE || 'https://nominatim.openstreetmap.org';
+const UA = 'Butlr/1.0 (admin+butlr@designerwallcoverings.com)';
+
+let LAST_REQ_AT = 0;
+async function rateLimitedFetch(url) {
+  const now = Date.now();
+  const minDelay = 1100; // 1.1s between requests
+  const wait = Math.max(0, LAST_REQ_AT + minDelay - now);
+  if (wait > 0) await new Promise(r => setTimeout(r, wait));
+  LAST_REQ_AT = Date.now();
+  return fetch(url, { headers: { 'User-Agent': UA, 'Accept-Language': 'en-US' } });
+}
+
+// ZIP → lat/lng resolution. Cached because zip→lat is stable.
+const ZIP_CACHE = new Map();
+async function resolveZip(zip) {
+  if (!zip) return null;
+  if (ZIP_CACHE.has(zip)) return ZIP_CACHE.get(zip);
+  try {
+    const url = `${NOMINATIM_BASE}/search?postalcode=${encodeURIComponent(zip)}&country=us&format=json&limit=1`;
+    const r = await rateLimitedFetch(url);
+    const j = await r.json();
+    if (j && j[0]) {
+      const out = { lat: parseFloat(j[0].lat), lng: parseFloat(j[0].lon) };
+      ZIP_CACHE.set(zip, out);
+      return out;
+    }
+  } catch {}
+  return null;
+}
+
+function viewboxAround(lat, lng, kmRadius = 25) {
+  // ~111 km per degree lat; lng scales by cos(lat).
+  const dLat = kmRadius / 111;
+  const dLng = kmRadius / (111 * Math.cos(lat * Math.PI / 180));
+  return `${lng - dLng},${lat + dLat},${lng + dLng},${lat - dLat}`;
+}
+
+router.get('/api/places/search', async (req, res) => {
+  const q = String(req.query.q || '').trim().slice(0, 100);
+  const zip = String(req.query.zip || '').trim().slice(0, 10);
+  const lat = parseFloat(req.query.lat);
+  const lng = parseFloat(req.query.lng);
+  if (!q || q.length < 2) return res.json({ ok: true, results: [] });
+
+  let centerLat = Number.isFinite(lat) ? lat : null;
+  let centerLng = Number.isFinite(lng) ? lng : null;
+  if (centerLat === null && zip) {
+    const z = await resolveZip(zip);
+    if (z) { centerLat = z.lat; centerLng = z.lng; }
+  }
+  if (centerLat === null) {
+    return res.status(400).json({
+      ok: false,
+      error: 'location_required',
+      message: 'Allow location or enter a US ZIP first so we can search nearby.',
+    });
+  }
+  const viewbox = viewboxAround(centerLat, centerLng, 25);
+
+  try {
+    const url = `${NOMINATIM_BASE}/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&extratags=1&namedetails=1&viewbox=${viewbox}&bounded=1&limit=10`;
+    const r = await rateLimitedFetch(url);
+    if (!r.ok) return res.status(502).json({ ok: false, error: 'nominatim_http_' + r.status });
+    const j = await r.json();
+    const results = (j || []).map(o => {
+      const addr = o.address || {};
+      const cityState = [addr.city || addr.town || addr.village || addr.hamlet, addr.state].filter(Boolean).join(', ');
+      const tags = o.extratags || {};
+      const phone = tags.phone || tags['contact:phone'] || null;
+      const website = tags.website || tags['contact:website'] || null;
+      return {
+        name: (o.namedetails && o.namedetails.name) || (o.display_name || '').split(',')[0],
+        city_state: cityState,
+        address: o.display_name,
+        phone,
+        website,
+        lat: parseFloat(o.lat),
+        lng: parseFloat(o.lon),
+        category: o.type || o.class,
+      };
+    }).filter(r => r.name);
+    res.json({ ok: true, results, center: { lat: centerLat, lng: centerLng } });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+module.exports = router;
diff --git a/scripts/status-snapshot.sh b/scripts/status-snapshot.sh
new file mode 100755
index 0000000..293e1cc
--- /dev/null
+++ b/scripts/status-snapshot.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+# Status-snapshot cron — runs every 2 minutes on prod, captures health
+# of butlr + wallco-ai (the two pm2 processes Steve cares about most),
+# appends JSONL to ~/status/butlr-wallco.jsonl, and rotates a "latest.json"
+# sidecar that surfaces at /admin/status (see routes/admin.js).
+#
+# Triggers an alert (writes to ~/status/ALERT-<ts>.txt) on:
+#   - HTTP healthcheck returns non-2xx
+#   - pm2 process is not 'online'
+#   - restart_time jumped by >5 since the last snapshot (flap detector)
+#
+# Wired via launchd: ~/Library/LaunchAgents/com.steve.butlr-status-cron.plist
+# (StartInterval 120). Idempotent — safe to run by hand.
+
+set -euo pipefail
+
+STATUS_DIR="${HOME}/status"
+mkdir -p "$STATUS_DIR"
+
+LOG="$STATUS_DIR/butlr-wallco.jsonl"
+LATEST="$STATUS_DIR/latest.json"
+
+ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
+
+# Healthchecks (curl --max-time 8)
+butlr_code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 8 https://butlr.agentabrams.com/healthz || echo "000")
+wallco_code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 8 https://wallco.ai/design/70n -L || echo "000")
+
+# pm2 stats — JSON via ssh; falls back to '?' on ssh failure
+pm2_blob=$(ssh -o ConnectTimeout=5 root@45.61.58.125 'pm2 jlist 2>/dev/null' 2>/dev/null || echo '[]')
+
+# Extract per-process stats in python (jq isn't always present)
+stats=$(echo "$pm2_blob" | python3 -c "
+import sys, json, time, os
+try:
+  d = json.load(sys.stdin)
+except Exception:
+  print(json.dumps({'butlr': None, 'wallco-ai': None}))
+  sys.exit(0)
+out = {}
+for n in ('butlr', 'wallco-ai'):
+  proc = next((p for p in d if p['name'] == n), None)
+  if not proc:
+    out[n] = None
+    continue
+  e = proc.get('pm2_env', {})
+  age_s = int(time.time() * 1000 - e.get('pm_uptime', 0)) // 1000
+  out[n] = {
+    'pid': proc.get('pid'),
+    'uptime_s': age_s,
+    'restarts': e.get('restart_time', 0),
+    'status': e.get('status'),
+    'memory_mb': round(proc.get('monit', {}).get('memory', 0) / 1024 / 1024, 1),
+    'cpu_pct': proc.get('monit', {}).get('cpu', 0),
+  }
+print(json.dumps(out))
+")
+
+# Build snapshot JSON
+snapshot=$(python3 -c "
+import json, sys
+stats = json.loads('$stats')
+snap = {
+  'ts': '$ts',
+  'butlr': {
+    'healthz_code': '$butlr_code',
+    'pm2': stats.get('butlr'),
+  },
+  'wallco_ai': {
+    'healthz_code': '$wallco_code',
+    'pm2': stats.get('wallco-ai'),
+  },
+}
+print(json.dumps(snap))
+")
+
+# Append to log + update latest
+echo "$snapshot" >> "$LOG"
+echo "$snapshot" > "$LATEST"
+
+# Alert detection
+alerts=()
+if [[ ! "$butlr_code" =~ ^2 ]]; then alerts+=("butlr healthz=$butlr_code"); fi
+if [[ ! "$wallco_code" =~ ^2 ]]; then alerts+=("wallco healthz=$wallco_code"); fi
+
+# Flap detection: compare restart_time to prior snapshot
+prior=$(tail -2 "$LOG" 2>/dev/null | head -1 || echo "")
+if [[ -n "$prior" ]] && [[ "$prior" != "$snapshot" ]]; then
+  flap_check=$(python3 -c "
+import json
+prev = json.loads('''$prior''')
+curr = json.loads('''$snapshot''')
+out = []
+for k, label in [('butlr', 'butlr'), ('wallco_ai', 'wallco-ai')]:
+  pp = (prev.get(k, {}) or {}).get('pm2') or {}
+  cp = (curr.get(k, {}) or {}).get('pm2') or {}
+  pr = pp.get('restarts', 0) or 0
+  cr = cp.get('restarts', 0) or 0
+  if cr - pr > 5:
+    out.append(f'{label} flap: {pr}→{cr} (+{cr-pr})')
+print('\n'.join(out))
+" 2>/dev/null || echo "")
+  if [[ -n "$flap_check" ]]; then alerts+=("$flap_check"); fi
+fi
+
+if (( ${#alerts[@]} > 0 )); then
+  alert_file="$STATUS_DIR/ALERT-$(date -u +"%Y%m%dT%H%M%SZ").txt"
+  {
+    echo "Time: $ts"
+    echo ""
+    echo "Issues:"
+    for a in "${alerts[@]}"; do echo "  - $a"; done
+    echo ""
+    echo "Snapshot:"
+    echo "$snapshot" | python3 -m json.tool
+  } > "$alert_file"
+fi
+
+# Trim log to last 10000 entries (keep ~14 days at 2-min cadence)
+if [[ -f "$LOG" ]]; then
+  lines=$(wc -l < "$LOG")
+  if (( lines > 10000 )); then
+    tail -10000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
+  fi
+fi
diff --git a/server.js b/server.js
index 9f8e3f4..55de5d7 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,7 @@ const twilioWebhooks = require('./routes/twilio-webhooks');
 const vapiWebhooks = require('./routes/vapi-webhooks');
 const adminRoutes = require('./routes/admin');
 const uploadsRoutes = require('./routes/uploads');
+const placesRoutes = require('./routes/places');
 const { attachListenBridge } = require('./lib/listen-bridge');
 const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
 const users = require('./lib/users');
@@ -142,6 +143,7 @@ app.use('/', listenRoutes);
 app.use('/', agentLogRoutes);
 app.use('/', adminRoutes);
 app.use('/', uploadsRoutes);
+app.use('/', placesRoutes);
 app.use('/', publicRoutes);
 
 app.use((req, res) => {
diff --git a/views/public/wizard-2-business.ejs b/views/public/wizard-2-business.ejs
index 6a2b05f..7c61834 100644
--- a/views/public/wizard-2-business.ejs
+++ b/views/public/wizard-2-business.ejs
@@ -8,7 +8,31 @@
       <%- include('../partials/wizard-progress', { step }) %>
 
       <h1>Who do we call?</h1>
-      <p class="muted">Pick from the directory below, or scroll down to enter a custom business.</p>
+      <p class="muted">Search by name (after you share location or enter ZIP), pick from the directory, or scroll down to enter manually.</p>
+
+      <!-- Location-gated business search (OpenStreetMap Nominatim) -->
+      <section style="padding:16px; border:1px solid var(--line); border-radius:10px; background:var(--panel); margin-bottom:20px;">
+        <div id="loc-gate">
+          <strong>📍 Location required for search</strong>
+          <p class="muted small" style="margin:6px 0 12px;">Searching globally for "AAA" returns junk. Share location OR enter a US ZIP to bias to your area (25 km radius).</p>
+          <div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
+            <button type="button" id="loc-btn" style="padding:8px 16px;">📍 Use my location</button>
+            <span class="muted small">or</span>
+            <input id="zip-input" inputmode="numeric" maxlength="5" placeholder="ZIP" style="padding:8px 12px; width:80px; border:1px solid var(--line); border-radius:6px;" />
+            <button type="button" id="zip-btn" style="padding:8px 16px;">Use ZIP</button>
+            <span id="loc-status" class="muted small"></span>
+          </div>
+        </div>
+
+        <div id="search-active" style="display:none;">
+          <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
+            <strong>🔎 Search businesses</strong>
+            <a href="#" id="loc-change" class="muted small">change location</a>
+          </div>
+          <input id="search-input" placeholder="Start typing a business name..." style="width:100%; box-sizing:border-box; padding:10px 14px; border:1px solid var(--line); border-radius:6px; font-size:16px;" />
+          <div id="search-results" style="display:flex; flex-direction:column; gap:6px; margin-top:10px;"></div>
+        </div>
+      </section>
 
       <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
         <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
@@ -112,4 +136,110 @@
     });
   });
 })();
+
+// ── Location-gated places search (Nominatim) ────────────────────────
+(function() {
+  const locGate    = document.getElementById('loc-gate');
+  const searchBox  = document.getElementById('search-active');
+  const locStatus  = document.getElementById('loc-status');
+  const locBtn     = document.getElementById('loc-btn');
+  const zipBtn     = document.getElementById('zip-btn');
+  const zipInput   = document.getElementById('zip-input');
+  const locChange  = document.getElementById('loc-change');
+  const qInput     = document.getElementById('search-input');
+  const results    = document.getElementById('search-results');
+  let location = null;  // {lat,lng} or {zip}
+
+  // Restore prior location from localStorage
+  try {
+    const saved = JSON.parse(localStorage.getItem('butlr.search.location') || 'null');
+    if (saved && (saved.zip || (saved.lat && saved.lng))) {
+      location = saved;
+      showSearchBox();
+    }
+  } catch {}
+
+  function setLoc(loc) {
+    location = loc;
+    try { localStorage.setItem('butlr.search.location', JSON.stringify(loc)); } catch {}
+    showSearchBox();
+  }
+  function showSearchBox() {
+    locGate.style.display = 'none';
+    searchBox.style.display = '';
+    qInput.focus();
+  }
+  function showGate() {
+    locGate.style.display = '';
+    searchBox.style.display = 'none';
+    location = null;
+    try { localStorage.removeItem('butlr.search.location'); } catch {}
+  }
+
+  locBtn.addEventListener('click', () => {
+    if (!navigator.geolocation) { locStatus.textContent = 'geolocation not supported'; locStatus.style.color = '#d33'; return; }
+    locStatus.textContent = 'requesting…';
+    navigator.geolocation.getCurrentPosition(
+      pos => { setLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); },
+      err => { locStatus.textContent = '✗ ' + err.message; locStatus.style.color = '#d33'; },
+      { enableHighAccuracy: false, timeout: 8000 }
+    );
+  });
+  zipBtn.addEventListener('click', () => {
+    const z = (zipInput.value || '').trim();
+    if (!/^\d{5}$/.test(z)) { locStatus.textContent = 'enter a 5-digit ZIP'; locStatus.style.color = '#d33'; return; }
+    setLoc({ zip: z });
+  });
+  if (locChange) locChange.addEventListener('click', e => { e.preventDefault(); showGate(); });
+
+  let timer = null;
+  qInput.addEventListener('input', () => {
+    clearTimeout(timer);
+    timer = setTimeout(() => doSearch(qInput.value.trim()), 350);
+  });
+
+  async function doSearch(q) {
+    if (!q || q.length < 2) { results.innerHTML = ''; return; }
+    if (!location) return;
+    results.innerHTML = '<p class="muted small">searching…</p>';
+    const params = new URLSearchParams({ q });
+    if (location.zip) params.set('zip', location.zip);
+    if (location.lat) { params.set('lat', location.lat); params.set('lng', location.lng); }
+    try {
+      const r = await fetch('/api/places/search?' + params.toString());
+      const j = await r.json();
+      if (!j.ok) { results.innerHTML = '<p class="muted small" style="color:#d33;">' + (j.message || j.error) + '</p>'; return; }
+      if (!j.results.length) { results.innerHTML = '<p class="muted small">no nearby matches</p>'; return; }
+      results.innerHTML = j.results.map((b, i) => `
+        <div class="place-result" data-i="${i}" style="padding:10px 12px; border:1px solid var(--line); border-radius:6px; cursor:pointer; background:var(--panel-2);">
+          <strong>${escapeHtml(b.name)}</strong>
+          ${b.phone ? `<span class="muted small" style="margin-left:8px;">📞 ${escapeHtml(b.phone)}</span>` : '<span class="muted small" style="margin-left:8px; color:#d33;">no phone on file</span>'}
+          <div class="muted small">${escapeHtml(b.city_state || b.address.split(',').slice(0,3).join(','))}</div>
+        </div>
+      `).join('');
+      results.querySelectorAll('.place-result').forEach((el, idx) => {
+        el.addEventListener('click', () => pickResult(j.results[idx]));
+      });
+    } catch (e) {
+      results.innerHTML = '<p class="muted small" style="color:#d33;">' + e.message + '</p>';
+    }
+  }
+
+  function pickResult(b) {
+    // Set the "custom" radio + fill name/phone fields if present.
+    const customRadio = document.querySelector('input[name="business_slug"][value="__custom__"]');
+    if (customRadio) { customRadio.checked = true; customRadio.dispatchEvent(new Event('change', { bubbles: true })); }
+    const nameI = document.querySelector('input[name="business_name"]');
+    const phoneI = document.querySelector('input[name="business_phone"]');
+    if (nameI) nameI.value = b.name;
+    if (phoneI && b.phone) phoneI.value = b.phone;
+    if (!b.phone && phoneI) { phoneI.focus(); phoneI.placeholder = 'no phone on file — enter manually'; }
+    // Scroll to the custom-entry block
+    if (phoneI) phoneI.scrollIntoView({ behavior: 'smooth', block: 'center' });
+  }
+
+  function escapeHtml(s) {
+    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
+  }
+})();
 </script>

← 986f634 upload-watcher: auto-import from connected folder every 30s  ·  back to Butlr  ·  loosen phone validation + add Retry button on call detail + 65c8fc1 →