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