← 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');
});