← back to Butlr
snapshot: backup uncommitted work (4 files)
96ffa092d0e6e5c289c7c4c6a23ed7501c575f79 · 2026-05-13 12:14:26 -0700 · SteveStudio2
Files touched
A routes/places.jsA scripts/status-snapshot.shM server.jsM views/public/wizard-2-business.ejs
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 →