← back to Butlr

routes/places.js

103 lines

// 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;