← back to La Socrata Ingester
viewer/server.js
142 lines
import express from 'express';
import { fileURLToPath } from 'url';
import path from 'path';
import pg from 'pg';
const { Pool } = pg;
const pool = process.env.DATABASE_URL
? new Pool({ connectionString: process.env.DATABASE_URL })
: new Pool({ database: process.env.REALESTATE_DB || 'realestate' });
const app = express();
const PORT = process.env.PERMIT_VIEWER_PORT || 9814;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// --- Basic auth (admin / DW2024!) ---
const USER = process.env.VIEWER_USER || 'admin';
const PASS = process.env.VIEWER_PASS || 'DW2024!';
app.use((req, res, next) => {
if (process.env.VIEWER_NO_AUTH === '1') return next(); // local screenshot/dev only
const hdr = req.headers.authorization || '';
const [, b64] = hdr.split(' ');
const [u, p] = Buffer.from(b64 || '', 'base64').toString().split(':');
if (u === USER && p === PASS) return next();
res.set('WWW-Authenticate', 'Basic realm="LA Building Permits"').status(401).send('Auth required');
});
app.use(express.static(path.join(__dirname, 'public')));
// Building permits only (the 3 Bldg-* datasets; trade permits excluded).
const BASE = `p.dataset_id IN ('pi9x-tg5x','dyxf-7hc4','e67z-kt2n')`;
const ERA = `CASE p.dataset_id WHEN 'pi9x-tg5x' THEN '2020-present' WHEN 'dyxf-7hc4' THEN '2010-2019' WHEN 'e67z-kt2n' THEN 'pre-2010' END`;
// Build a parameterized WHERE from query filters. Returns { sql, params }.
function buildWhere(qp, params = []) {
const w = [BASE];
const add = (v) => { params.push(v); return `$${params.length}`; };
if (qp.q) w.push(`p.primary_address ILIKE ${add('%' + qp.q + '%')}`);
if (qp.era) {
const map = { '2020-present': 'pi9x-tg5x', '2010-2019': 'dyxf-7hc4', 'pre-2010': 'e67z-kt2n' };
if (map[qp.era]) w.push(`p.dataset_id = ${add(map[qp.era])}`);
}
if (qp.work_type) w.push(`p.permit_type = ${add(qp.work_type)}`);
if (qp.building_type) w.push(`p.permit_sub_type = ${add(qp.building_type)}`);
if (qp.status) w.push(`p.status_desc = ${add(qp.status)}`);
if (qp.cd) w.push(`p.council_district = ${add(qp.cd)}`);
if (qp.zip) w.push(`p.zip_code = ${add(qp.zip)}`);
if (qp.min_value) w.push(`p.valuation >= ${add(Number(qp.min_value))}`);
if (qp.since) w.push(`p.issue_date >= ${add(qp.since)}`);
return { sql: w.join(' AND '), params };
}
// Named sort presets (back-compat).
const SORTS = {
newest: 'p.issue_date DESC NULLS LAST',
oldest: 'p.issue_date ASC NULLS LAST',
value_desc: 'p.valuation DESC NULLS LAST',
value_asc: 'p.valuation ASC NULLS LAST',
address: 'p.primary_address ASC',
};
// Per-column sort whitelist (col name -> SQL expr). Enables sort=<col>&dir=asc|desc.
const COLS = {
lead_score: 'permit_lead_score_v2(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc,a.total_value,a.year_built)',
issued: 'p.issue_date', address: 'p.primary_address', zip: 'p.zip_code',
cd: 'p.council_district', work_type: 'p.permit_type', building_type: 'p.permit_sub_type',
use_desc: 'p.use_desc', status: 'p.status_desc', project_value: 'p.valuation',
apn: 'p.apn', assessed_value: 'a.total_value', year_built: 'a.year_built',
sqft: 'a.sqft_main', era: 'p.dataset_id', permit_nbr: 'p.permit_nbr',
};
function orderBy(sort, dir) {
if (COLS[sort]) return `${COLS[sort]} ${dir === 'asc' ? 'ASC' : 'DESC'} NULLS LAST`;
return SORTS[sort] || 'permit_lead_score_v2(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc,a.total_value,a.year_built) DESC NULLS LAST';
}
// --- Paginated, filtered, sorted permits ---
app.get('/api/permits', async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit) || 60, 200);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * limit;
const order = orderBy(req.query.sort, req.query.dir);
const { sql: where, params } = buildWhere(req.query);
const rowsSql = `
SELECT permit_lead_score_v2(p.issue_date,p.valuation,p.permit_type,p.permit_sub_type,p.status_desc,a.total_value,a.year_built) AS lead_score,
${ERA} AS era, p.permit_nbr, p.issue_date, p.primary_address AS address, p.zip_code AS zip,
p.council_district AS cd, p.permit_type AS work_type, p.permit_sub_type AS building_type,
p.use_desc, p.status_desc AS status, p.valuation AS project_value, p.apn,
a.total_value AS assessed_value, a.year_built, a.sqft_main AS sqft, p.lat, p.lon
FROM la_building_permits_raw p
LEFT JOIN la_assessor_parcels_raw a ON a.ain = p.apn AND a.roll_year = '2025'
WHERE ${where}
ORDER BY ${order}
LIMIT ${limit} OFFSET ${offset}`;
const rows = (await pool.query(rowsSql, params)).rows;
// total (exact when filtered; cached constant when unfiltered for speed)
const isFiltered = Object.keys(req.query).some((k) => ['q','era','work_type','building_type','status','cd','zip','min_value','since'].includes(k) && req.query[k]);
let total;
if (isFiltered) {
total = Number((await pool.query(`SELECT count(*) c FROM la_building_permits_raw p WHERE ${where}`, params)).rows[0].c);
} else {
total = 1578997;
}
res.json({ total, page, limit, rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- Facet counts (respect current filters) ---
app.get('/api/facets', async (req, res) => {
try {
const facet = async (expr) => {
const { sql: where, params } = buildWhere(req.query);
const q = `SELECT ${expr} AS v, count(*) c FROM la_building_permits_raw p WHERE ${where} AND ${expr} IS NOT NULL GROUP BY 1 ORDER BY c DESC`;
return (await pool.query(q, params)).rows;
};
const [era, work_type, building_type, status, cd, zip] = await Promise.all([
facet(ERA), facet('p.permit_type'), facet('p.permit_sub_type'),
facet('p.status_desc'), facet('p.council_district'),
(async () => { const { sql: where, params } = buildWhere(req.query);
return (await pool.query(`SELECT p.zip_code v, count(*) c FROM la_building_permits_raw p WHERE ${where} AND p.zip_code IS NOT NULL GROUP BY 1 ORDER BY c DESC LIMIT 40`, params)).rows; })(),
]);
res.json({ era, work_type, building_type, status, cd, zip });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- Single permit detail ---
app.get('/api/permit/:nbr', async (req, res) => {
try {
const q = `
SELECT ${ERA} AS era, p.*, a.total_value AS parcel_assessed, a.year_built AS parcel_year_built,
a.sqft_main AS parcel_sqft, a.use_type AS parcel_use, a.property_location AS parcel_address
FROM la_building_permits_raw p
LEFT JOIN la_assessor_parcels_raw a ON a.ain = p.apn AND a.roll_year = '2025'
WHERE ${BASE} AND p.permit_nbr = $1 LIMIT 1`;
const row = (await pool.query(q, [req.params.nbr])).rows[0];
if (!row) return res.status(404).json({ error: 'not found' });
res.json(row);
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.listen(PORT, () => console.log(`LA Building Permits viewer → http://127.0.0.1:${PORT} (admin/${PASS})`));