← back to Permit Radar
server.js
302 lines
// permit-radar — zero-dep Node http server
// Serves permit directory with sort/filter, lead funnel, and admin dashboard.
// PORT from env (default 3905). BASIC_AUTH=user:pass gates /admin.
//
// REAL FEED SEAM: see scripts/ingest-stub.js for how a real county permit
// feed would populate data/permits.json. The sample data is representative only.
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.env.PORT || '3905', 10);
const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate /admin
const DIR = __dirname;
// ── Data helpers ─────────────────────────────────────────────────────────────
function loadJSON(file) {
try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', file), 'utf8')); } catch { return []; }
}
function saveJSON(file, data) {
fs.writeFileSync(path.join(DIR, 'data', file), JSON.stringify(data, null, 2), 'utf8');
}
function loadPermits() { return loadJSON('permits.json'); }
function loadSubscribers() { return loadJSON('subscribers.json'); }
function loadLeadRequests() { return loadJSON('lead_requests.json'); }
function loadShopifyCatalog() {
try {
const raw = JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'shopify-catalog.json'), 'utf8'));
return raw && raw.products ? raw : { checkout_domain: 'www.designerwallcoverings.com', products: Array.isArray(raw) ? raw : [] };
} catch { return { checkout_domain: 'www.designerwallcoverings.com', products: [] }; }
}
// ── Permit sort ───────────────────────────────────────────────────────────────
function sortPermits(items, mode) {
const a = items.slice();
switch (mode) {
case 'date_asc': return a.sort((x, y) => x.date_filed.localeCompare(y.date_filed));
case 'date_desc': return a.sort((x, y) => y.date_filed.localeCompare(x.date_filed));
case 'valuation_asc': return a.sort((x, y) => (x.valuation || 0) - (y.valuation || 0));
case 'valuation_desc': return a.sort((x, y) => (y.valuation || 0) - (x.valuation || 0));
case 'city': return a.sort((x, y) => (x.city || '').localeCompare(y.city || ''));
case 'type': return a.sort((x, y) => (x.permit_type || '').localeCompare(y.permit_type || ''));
case 'address': return a.sort((x, y) => (x.address || '').localeCompare(y.address || ''));
default: return a; // 'newest' = file order (newest filed last in seed; reversed by date_desc)
}
}
// ── Permit filter ─────────────────────────────────────────────────────────────
function filterPermits(items, params) {
let out = items;
const q = (params.get('q') || '').toLowerCase().trim();
const jurisdiction = (params.get('jurisdiction') || '').toLowerCase().trim();
const permit_type = (params.get('permit_type') || '').toLowerCase().trim();
const status = (params.get('status') || '').toLowerCase().trim();
const val_min = parseFloat(params.get('val_min') || '0') || 0;
const val_max = parseFloat(params.get('val_max') || '0') || 0;
const date_from = params.get('date_from') || '';
const date_to = params.get('date_to') || '';
if (q) {
out = out.filter(p =>
(p.address || '').toLowerCase().includes(q) ||
(p.city || '').toLowerCase().includes(q) ||
(p.jurisdiction || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.contractor || '').toLowerCase().includes(q) ||
(p.permit_type_label || '').toLowerCase().includes(q)
);
}
if (jurisdiction) out = out.filter(p => (p.jurisdiction || '').toLowerCase().includes(jurisdiction));
if (permit_type) out = out.filter(p => (p.permit_type || '').toLowerCase() === permit_type);
if (status) out = out.filter(p => (p.status || '').toLowerCase() === status);
if (val_min > 0) out = out.filter(p => (p.valuation || 0) >= val_min);
if (val_max > 0) out = out.filter(p => (p.valuation || 0) <= val_max);
if (date_from) out = out.filter(p => p.date_filed >= date_from);
if (date_to) out = out.filter(p => p.date_filed <= date_to);
return out;
}
// ── Auth helpers ──────────────────────────────────────────────────────────────
function adminAuthed(req) {
if (!BASIC_AUTH) return true;
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
if (!m) return false;
try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
}
// ── MIME ──────────────────────────────────────────────────────────────────────
function mime(f) {
if (f.endsWith('.html')) return 'text/html; charset=utf-8';
if (f.endsWith('.css')) return 'text/css';
if (f.endsWith('.js')) return 'application/javascript';
if (f.endsWith('.json')) return 'application/json';
if (f.endsWith('.ico')) return 'image/x-icon';
return 'text/plain';
}
// ── Body parser ───────────────────────────────────────────────────────────────
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
catch { resolve({}); }
});
req.on('error', reject);
});
}
// ── Server ────────────────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://localhost:${PORT}`);
const pathname = parsed.pathname;
const params = parsed.searchParams;
const method = req.method.toUpperCase();
// ── JSON API ────────────────────────────────────────────────────────────────
if (pathname === '/api/permits' && method === 'GET') {
const raw = loadPermits();
const filtered = filterPermits(raw, params);
const sorted = sortPermits(filtered, params.get('sort') || 'date_desc');
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ count: sorted.length, permits: sorted, sample: true }));
}
if (pathname.startsWith('/api/permits/') && method === 'GET') {
const id = decodeURIComponent(pathname.replace('/api/permits/', ''));
const permit = loadPermits().find(p => p.id === id);
if (!permit) {
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'not found' }));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ permit, sample: true }));
}
if (pathname === '/api/subscribe' && method === 'POST') {
const body = await readBody(req);
if (!body.email || !body.trade_type) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'email and trade_type required' }));
}
const subs = loadSubscribers();
const record = {
id: 'SUB-' + Date.now(),
email: body.email,
trade_type: body.trade_type,
service_area: body.service_area || '',
jurisdictions: body.jurisdictions || [],
permit_types: body.permit_types || [],
plan: body.plan || 'basic',
created_at: new Date().toISOString()
};
subs.push(record);
saveJSON('subscribers.json', subs);
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, id: record.id, message: 'Subscription recorded.' }));
}
if (pathname === '/api/lead-request' && method === 'POST') {
const body = await readBody(req);
if (!body.permit_id || !body.email) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'permit_id and email required' }));
}
const permits = loadPermits();
const permit = permits.find(p => p.id === body.permit_id);
const reqs = loadLeadRequests();
const record = {
id: 'LR-' + Date.now(),
permit_id: body.permit_id,
permit_address: permit ? (permit.address + ', ' + permit.city) : '',
permit_valuation: permit ? permit.valuation : 0,
name: body.name || '',
email: body.email,
phone: body.phone || '',
trade_type: body.trade_type || '',
message: body.message || '',
created_at: new Date().toISOString()
};
reqs.push(record);
saveJSON('lead_requests.json', reqs);
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, id: record.id, message: 'Lead request received.' }));
}
// ── Shopify cross-sell (static catalog, no token, read-only) ──────────────────
if (pathname === '/api/shopify-crosssell' && method === 'GET') {
const catalog = loadShopifyCatalog();
const prods = catalog.products || [];
// Prefer buyable products so most cards show a real price; fall back to all if needed
const pool = prods.filter(p => p.buyable).length >= 6
? prods.filter(p => p.buyable)
: prods;
const n = Math.min(6, pool.length);
// Fisher-Yates shuffle then take n
const shuffled = pool.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const picks = shuffled.slice(0, n).map(p => ({
title: p.title,
handle: p.handle,
image: p.image,
price_display: p.price_display || 'Roll price on request',
sample_only: p.sample_only || false,
vendor: p.vendor,
type: p.type,
url: p.product_url || ('https://' + catalog.checkout_domain + '/products/' + p.handle)
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ products: picks, checkout_domain: catalog.checkout_domain }));
}
// Admin API — gated
if (pathname === '/api/admin/subscribers' && method === 'GET') {
if (!adminAuthed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
return res.end('auth required');
}
const subs = loadSubscribers();
const sort = params.get('sort') || 'newest';
const sorted = sort === 'oldest'
? subs.slice().sort((a, b) => a.created_at.localeCompare(b.created_at))
: subs.slice().sort((a, b) => b.created_at.localeCompare(a.created_at));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ count: sorted.length, subscribers: sorted }));
}
if (pathname === '/api/admin/lead-requests' && method === 'GET') {
if (!adminAuthed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
return res.end('auth required');
}
const reqs = loadLeadRequests();
const sort = params.get('sort') || 'newest';
const sorted = sort === 'oldest'
? reqs.slice().sort((a, b) => a.created_at.localeCompare(b.created_at))
: reqs.slice().sort((a, b) => b.created_at.localeCompare(a.created_at));
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ count: sorted.length, lead_requests: sorted }));
}
// ── Static files ────────────────────────────────────────────────────────────
// /admin → admin.html (gated)
if (pathname === '/admin' || pathname === '/admin/') {
if (!adminAuthed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="permit-radar-admin"' });
return res.end('auth required');
}
const fp = path.join(DIR, 'public', 'admin.html');
if (fs.existsSync(fp)) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404); return res.end('admin.html not found');
}
// /permit/:id → permit.html (SPA-style; ID parsed on client from location)
if (pathname.startsWith('/permit/')) {
const fp = path.join(DIR, 'public', 'permit.html');
if (fs.existsSync(fp)) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404); return res.end('not found');
}
// static files in /public
const relPath = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const safe = path.normalize(relPath).replace(/^(\.\.(\/|\\|$))+/, '');
const fp = path.join(DIR, 'public', safe);
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
res.writeHead(200, { 'Content-Type': mime(fp) });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
server.listen(PORT, '127.0.0.1', function () {
const addr = this.address();
console.log('[permit-radar] http://localhost:' + addr.port);
console.log('[permit-radar] Admin: BASIC_AUTH=admin:DW2024! PORT=3905 node server.js, then visit /admin');
console.log('[permit-radar] NOTE: permit data is SAMPLE/REPRESENTATIVE — not real county data.');
console.log('[permit-radar] Real feed seam: see scripts/ingest-stub.js');
});