[object Object]

← back to Ca Donations

ca-donations: Express server + Basic-Auth + searchable two-family grid UI

1be72ab541883d29d1ae156d93daf0cc7f1d3b16 · 2026-08-21 08:14:26 -0700 · Steve

- server.js: 401-gated API (/healthz, /api/stats, /api/orgs, /api/org/:ein, /api/grants, /api/political) with ILIKE search + drill endpoints
- public/index.html: two-family grid with mandatory sort + density (localStorage), href-drill on every chip, honest 'ingest pending' state for political + grants
- Verified: auth gate 401->200, stats=161410 orgs, live search returns real CZI/foundation records

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1be72ab541883d29d1ae156d93daf0cc7f1d3b16
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 21 08:14:26 2026 -0700

    ca-donations: Express server + Basic-Auth + searchable two-family grid UI
    
    - server.js: 401-gated API (/healthz, /api/stats, /api/orgs, /api/org/:ein, /api/grants, /api/political) with ILIKE search + drill endpoints
    - public/index.html: two-family grid with mandatory sort + density (localStorage), href-drill on every chip, honest 'ingest pending' state for political + grants
    - Verified: auth gate 401->200, stats=161410 orgs, live search returns real CZI/foundation records
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 104 ++++++++++++++++++++++++++++++++
 2 files changed, 279 insertions(+)

diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..5bb08bd
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,175 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CA Donations — Public Records</title>
+<style>
+  :root { --cols: 4; --fs: 13px; }
+  * { box-sizing: border-box; }
+  body { margin: 0; font: var(--fs)/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color: #1a1a1a; background: #f6f7f9; }
+  header { padding: 14px 18px; background: #0f2540; color: #fff; }
+  header h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: .2px; }
+  header .sub { opacity: .8; font-size: 12px; margin-top: 3px; }
+  .stats { display: flex; gap: 16px; padding: 8px 18px; background: #16324f; color: #cfe0f5; font-size: 12px; flex-wrap: wrap; }
+  .stats b { color: #fff; }
+  .tabs { display: flex; gap: 4px; padding: 8px 18px 0; background: #16324f; }
+  .tabs button { border: 0; padding: 8px 14px; border-radius: 6px 6px 0 0; background: #23456b; color: #cfe0f5; cursor: pointer; font-size: 13px; }
+  .tabs button.active { background: #f6f7f9; color: #0f2540; font-weight: 600; }
+  .controls { display: flex; gap: 10px; align-items: center; padding: 12px 18px; background: #fff; border-bottom: 1px solid #e3e7ec; flex-wrap: wrap; position: sticky; top: 0; z-index: 5; }
+  .controls input[type=text], .controls select { padding: 7px 9px; border: 1px solid #cbd2da; border-radius: 6px; font-size: 13px; }
+  .controls input[type=text] { min-width: 240px; }
+  .controls label { font-size: 12px; color: #556; display: flex; align-items: center; gap: 6px; }
+  .grid { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 10px; padding: 16px 18px; }
+  .card { background: #fff; border: 1px solid #e3e7ec; border-radius: 8px; padding: 10px 12px; }
+  .card .name { font-weight: 600; font-size: calc(var(--fs) + 1px); margin-bottom: 5px; }
+  .kv { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
+  .chip { background: #eef2f7; border: 1px solid #dbe2ea; border-radius: 5px; padding: 2px 7px; font-size: 11px; cursor: pointer; }
+  .chip:hover { background: #dce7f5; }
+  .chip .lbl { color: #7a8797; text-transform: uppercase; font-size: 9px; letter-spacing: .4px; margin-right: 4px; }
+  .amt { color: #0a7d33; font-weight: 600; }
+  .status-may-operate { color: #0a7d33; }
+  .status-may-not-operate, .status-not-operating { color: #b4232a; }
+  .empty { padding: 40px 18px; color: #667; text-align: center; }
+  .pending { background: #fff6e0; border: 1px solid #f0d98a; color: #7a5c00; border-radius: 8px; padding: 16px; margin: 18px; }
+  .note { font-size: 11px; color: #7a8797; padding: 0 18px 16px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>California Donations — Public Records</h1>
+  <div class="sub">Political (donor-level, above the $100 CA itemization threshold) + Charitable (org &amp; foundation-grant records). Individual public-charity donors are not public.</div>
+</header>
+<div class="stats" id="stats">loading…</div>
+<div class="tabs">
+  <button data-tab="orgs" class="active">Charitable Orgs</button>
+  <button data-tab="grants">Foundation Grants</button>
+  <button data-tab="political">Political Contributions</button>
+</div>
+<div class="controls">
+  <input type="text" id="q" placeholder="Search…">
+  <select id="filter"></select>
+  <label>Sort
+    <select id="sort"></select>
+  </label>
+  <label>Density
+    <input type="range" id="density" min="2" max="7" step="1">
+  </label>
+</div>
+<div id="view"></div>
+<div class="note">Sources: CAL-ACCESS (state) · FEC bulk (federal) · local NetFile/Socrata · CA AG Registry · IRS 990/990-PF · ProPublica. All $0 public data. Every value is clickable to refine.</div>
+
+<script>
+const $ = (s) => document.querySelector(s);
+const view = $('#view');
+let tab = 'orgs';
+const state = { orgs:{q:'',filter:'',sort:'name'}, grants:{q:'',filter:'',sort:'amount'}, political:{q:'',filter:'',sort:'date'} };
+
+// Density persists (Steve's standing rule).
+const density = $('#density');
+density.value = localStorage.getItem('cad_density') || 4;
+document.documentElement.style.setProperty('--cols', density.value);
+density.oninput = () => { document.documentElement.style.setProperty('--cols', density.value); localStorage.setItem('cad_density', density.value); };
+
+const SORTS = {
+  orgs:      [['name','Name A→Z'],['status','Status'],['ntee','NTEE']],
+  grants:    [['amount','Amount ↓'],['year','Year ↓'],['grantor','Grantor']],
+  political: [['date','Date ↓'],['amount','Amount ↓'],['donor','Donor'],['recipient','Recipient']],
+};
+const FILTERS = {
+  orgs:      [['','All statuses'],['may-operate','May operate'],['may-not-operate','May not operate'],['undetermined','Undetermined'],['not-operating','Not operating']],
+  grants:    [['','All types'],['990pf_partxv','990-PF grants'],['990_sched_i','990 Sched I'],['990_sched_f','990 Sched F']],
+  political: [['','All jurisdictions'],['state','State'],['federal','Federal'],['local','Local']],
+};
+
+function chip(lbl, val, onClick) {
+  if (val == null || val === '') return '';
+  const safe = String(val).replace(/</g,'&lt;');
+  return `<span class="chip" data-click='${JSON.stringify(onClick)}'><span class="lbl">${lbl}</span>${safe}</span>`;
+}
+const money = (n) => n == null ? '' : '$' + Number(n).toLocaleString(undefined,{maximumFractionDigits:0});
+
+async function loadStats() {
+  const s = await (await fetch('/api/stats')).json();
+  $('#stats').innerHTML =
+    `<span><b>${s.charitable_orgs.toLocaleString()}</b> charitable orgs</span>` +
+    `<span><b>${s.charitable_grants.toLocaleString()}</b> foundation grants</span>` +
+    `<span><b>${s.political_contributions.toLocaleString()}</b> political contributions</span>`;
+}
+
+function syncControls() {
+  const st = state[tab];
+  $('#q').value = st.q;
+  $('#sort').innerHTML = SORTS[tab].map(([v,l]) => `<option value="${v}" ${v===st.sort?'selected':''}>${l}</option>`).join('');
+  $('#filter').innerHTML = FILTERS[tab].map(([v,l]) => `<option value="${v}" ${v===st.filter?'selected':''}>${l}</option>`).join('');
+}
+
+async function render() {
+  const st = state[tab];
+  let url, cards;
+  if (tab === 'orgs') {
+    url = `/api/orgs?q=${encodeURIComponent(st.q)}&status=${st.filter}&limit=200`;
+    const { rows } = await (await fetch(url)).json();
+    cards = rows.map(r => `<div class="card"><div class="name">${(r.name||'').replace(/</g,'&lt;')}</div>
+      <div class="kv">
+        ${chip('EIN', r.ein, {t:'orgs',q:r.ein})}
+        ${chip('City', r.city, {t:'orgs',q:r.city})}
+        ${r.ntee_code ? `<span class="chip" data-click='${JSON.stringify({t:'orgs',filterNtee:r.ntee_code})}'><span class="lbl">NTEE</span>${r.ntee_code}</span>`:''}
+        ${r.ca_ag_status ? `<span class="chip status-${r.ca_ag_status}" data-click='${JSON.stringify({t:'orgs',filter:r.ca_ag_status})}'><span class="lbl">CA AG</span>${r.ca_ag_status}</span>`:''}
+        ${chip('Grants', 'view →', {t:'grants',q:r.name})}
+      </div></div>`).join('');
+    view.innerHTML = rows.length ? `<div class="grid">${cards}</div>` : `<div class="empty">No orgs match.</div>`;
+  } else if (tab === 'grants') {
+    url = `/api/grants?grantor=${encodeURIComponent(st.q)}&grantee=${encodeURIComponent(st.q)}&limit=200`;
+    const { rows } = await (await fetch(`/api/grants?grantor=${encodeURIComponent(st.q)}&limit=200`)).json();
+    if (!rows.length) { view.innerHTML = `<div class="pending"><b>Foundation-grant ingest pending.</b> Grant records (990-PF Part XV, 990 Schedule I/F) load from IRS 990 XML in the charitable-grants node. The schema, API, and grid are live and will populate on that run.</div>`; return; }
+    cards = rows.map(r => `<div class="card"><div class="name">${(r.grantee_name||'').replace(/</g,'&lt;')} <span class="amt">${money(r.amount)}</span></div>
+      <div class="kv">
+        ${chip('Grantor', r.grantor_name, {t:'grants',q:r.grantor_name})}
+        ${chip('City', r.grantee_city, {t:'grants',q:r.grantee_city})}
+        ${chip('Year', r.tax_year, {t:'grants',q:''})}
+        ${chip('Type', r.grant_type, {t:'grants',q:''})}
+      </div></div>`).join('');
+    view.innerHTML = `<div class="grid">${cards}</div>`;
+  } else {
+    const { rows } = await (await fetch(`/api/political?donor=${encodeURIComponent(st.q)}&jurisdiction=${st.filter}&limit=200`)).json();
+    if (!rows.length) { view.innerHTML = `<div class="pending"><b>Political-contribution ingest pending.</b> Donor-level rows load from the CAL-ACCESS daily dump (state, incl. Forms 461/496/497), FEC bulk <code>itcont</code> filtered to CA (federal), and local NetFile/Socrata. The schema, API, and grid are live and will populate on that run.</div>`; return; }
+    cards = rows.map(r => `<div class="card"><div class="name">${(r.donor_name||'').replace(/</g,'&lt;')} <span class="amt">${money(r.amount)}</span></div>
+      <div class="kv">
+        ${chip('Employer', r.donor_employer, {t:'political',q:r.donor_employer})}
+        ${chip('City', r.donor_city, {t:'political',q:r.donor_city})}
+        ${chip('→', r.recipient_name, {t:'political',q:r.recipient_name})}
+        ${chip('Office', r.office, {t:'political',q:''})}
+        ${chip('Juris', r.jurisdiction, {t:'political',filter:r.jurisdiction})}
+        ${chip('Date', r.contribution_date, {t:'political',q:''})}
+      </div></div>`).join('');
+    view.innerHTML = `<div class="grid">${cards}</div>`;
+  }
+}
+
+// href-drill: clicking any chip refines the query.
+view.addEventListener('click', (e) => {
+  const el = e.target.closest('.chip'); if (!el || !el.dataset.click) return;
+  const o = JSON.parse(el.dataset.click);
+  if (o.t) tab = o.t;
+  document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
+  const st = state[tab];
+  if (o.q !== undefined) st.q = o.q;
+  if (o.filter !== undefined) st.filter = o.filter;
+  if (o.filterNtee !== undefined) st.q = o.filterNtee;
+  syncControls(); render();
+});
+
+$('#q').oninput = (e) => { state[tab].q = e.target.value; render(); };
+$('#sort').onchange = (e) => { state[tab].sort = e.target.value; render(); };
+$('#filter').onchange = (e) => { state[tab].filter = e.target.value; render(); };
+document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
+  tab = b.dataset.tab;
+  document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('active', x === b));
+  syncControls(); render();
+});
+
+syncControls(); loadStats(); render();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..6f4b037
--- /dev/null
+++ b/server.js
@@ -0,0 +1,104 @@
+// ca-donations — Basic-Auth searchable data product over CA donation public records.
+// Two families: charitable (orgs + grants) and political (donor-level contributions).
+import express from 'express';
+import { q } from './lib/db.js';
+
+const app = express();
+const PORT = process.env.PORT || 9926;
+const USER = process.env.BASIC_AUTH_USER || 'admin';
+const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
+
+// --- Basic Auth gate (401 = healthy). /healthz is open for smoke checks. ---
+app.use((req, res, next) => {
+  if (req.path === '/healthz') return next();
+  const h = req.headers.authorization || '';
+  const [, b64] = h.split(' ');
+  if (b64) {
+    const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
+    if (u === USER && p === PASS) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="ca-donations"');
+  return res.status(401).send('Auth required');
+});
+
+const like = (s) => `%${String(s).trim()}%`;
+
+app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'ca-donations' }));
+
+// Counts per family — the honest "what's loaded" signal for the UI.
+app.get('/api/stats', async (_req, res) => {
+  try {
+    const [orgs] = await q('SELECT count(*)::int n FROM charitable_orgs');
+    const [grants] = await q('SELECT count(*)::int n FROM charitable_grants');
+    const [pol] = await q('SELECT count(*)::int n FROM political_contributions');
+    const runs = await q(
+      `SELECT source_slug, status, rows_upsert, finished_at FROM ingest_runs
+       ORDER BY id DESC LIMIT 10`);
+    res.json({ charitable_orgs: orgs.n, charitable_grants: grants.n, political_contributions: pol.n, runs });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Charitable orgs search — drillable: each org links to /api/org/:ein.
+app.get('/api/orgs', async (req, res) => {
+  try {
+    const { q: term = '', status = '', ntee = '', limit = 100 } = req.query;
+    const where = [], params = [];
+    if (term)   { params.push(like(term));   where.push(`name ILIKE $${params.length}`); }
+    if (status) { params.push(status);        where.push(`ca_ag_status = $${params.length}`); }
+    if (ntee)   { params.push(like(ntee));    where.push(`ntee_code ILIKE $${params.length}`); }
+    params.push(Math.min(+limit || 100, 500));
+    const rows = await q(
+      `SELECT ein,name,city,state,ntee_code,subsection,ca_ag_status
+       FROM charitable_orgs ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+       ORDER BY name LIMIT $${params.length}`, params);
+    res.json({ rows });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Org detail + its grants (grantor and grantee sides).
+app.get('/api/org/:ein', async (req, res) => {
+  try {
+    const [org] = await q('SELECT * FROM charitable_orgs WHERE ein=$1', [req.params.ein]);
+    const grantsOut = await q('SELECT * FROM charitable_grants WHERE grantor_ein=$1 ORDER BY tax_year DESC, amount DESC LIMIT 200', [req.params.ein]);
+    res.json({ org: org || null, grants_out: grantsOut });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Charitable grants search (donor->recipient records from 990-PF/Sched I/F).
+app.get('/api/grants', async (req, res) => {
+  try {
+    const { grantor = '', grantee = '', year = '', limit = 100 } = req.query;
+    const where = [], params = [];
+    if (grantor) { params.push(like(grantor)); where.push(`grantor_name ILIKE $${params.length}`); }
+    if (grantee) { params.push(like(grantee)); where.push(`grantee_name ILIKE $${params.length}`); }
+    if (year)    { params.push(+year);          where.push(`tax_year = $${params.length}`); }
+    params.push(Math.min(+limit || 100, 500));
+    const rows = await q(
+      `SELECT grantor_ein,grantor_name,grantee_name,grantee_city,amount,tax_year,grant_type
+       FROM charitable_grants ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+       ORDER BY amount DESC NULLS LAST LIMIT $${params.length}`, params);
+    res.json({ rows });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Political donor-level contributions (above CA $100 itemization threshold).
+app.get('/api/political', async (req, res) => {
+  try {
+    const { donor = '', recipient = '', jurisdiction = '', employer = '', limit = 100 } = req.query;
+    const where = [], params = [];
+    if (donor)        { params.push(like(donor));        where.push(`donor_name ILIKE $${params.length}`); }
+    if (recipient)    { params.push(like(recipient));    where.push(`recipient_name ILIKE $${params.length}`); }
+    if (employer)     { params.push(like(employer));     where.push(`donor_employer ILIKE $${params.length}`); }
+    if (jurisdiction) { params.push(jurisdiction);       where.push(`jurisdiction = $${params.length}`); }
+    params.push(Math.min(+limit || 100, 500));
+    const rows = await q(
+      `SELECT donor_name,donor_employer,donor_city,amount,contribution_date,recipient_name,office,jurisdiction,form
+       FROM political_contributions ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+       ORDER BY contribution_date DESC NULLS LAST LIMIT $${params.length}`, params);
+    res.json({ rows });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.use(express.static('public'));
+
+app.listen(PORT, () => console.log(`ca-donations on :${PORT}`));

← 8c89370 ca-donations: scaffold + schema + charitable ingest pipeline  ·  back to Ca Donations  ·  auto-data-snapshot: 2026-08-21T08:45:49 (2 data files) — pac 5197daa →