← back to Petitionyour

lib/reps.js

237 lines

// $0 / free-only representative lookup + resolution.
//
// Two-layer design, honesty over false precision:
//
//  1. ZIP -> STATE: deterministic, offline, bundled USPS ZIP3-prefix range
//     table. Good enough to say "you're in California" for every 5-digit ZIP.
//
//  2. STATE + (district) -> ACTUAL MEMBERS: resolved against the bundled
//     public-domain `unitedstates/congress-legislators` dataset (see
//     scripts/refresh-legislators.js). SENATORS are resolved EXACTLY from the
//     state (every state has exactly 2). The HOUSE rep needs a congressional
//     district, which a ZIP alone cannot give unambiguously:
//       - Single-district ("at-large") states -> exactly 1 rep, unambiguous.
//       - Multi-district states -> we DO NOT guess. The caller can supply a
//         precise district (from the free U.S. Census geocoder, via a street
//         address) to pin the exact rep; otherwise we hand back the whole
//         state House delegation as *candidates* and say so plainly.
//
// We refuse to hardcode a stale name list (elections change officeholders);
// instead we ship a refreshable dataset + a refresh script. This is the only
// path that is both specific AND can't silently rot into misinformation.

const path = require('path');
const ranges = require('../data/zip3-state-ranges.json');

// ---- bundled member dataset (loaded once, cached) ----
let _members = null;
function loadMembers() {
  if (_members) return _members;
  // Resolve lazily so a missing/renamed file surfaces at call time, not require time.
  const raw = require(path.join(__dirname, '..', 'data', 'legislators-current.json'));
  _members = raw.map(normalizeMember).filter(Boolean);
  return _members;
}

function currentTerm(m) {
  return m.terms && m.terms.length ? m.terms[m.terms.length - 1] : null;
}

function normalizeMember(m) {
  const t = currentTerm(m);
  if (!t) return null;
  const name = (m.name && (m.name.official_full ||
    [m.name.first, m.name.last].filter(Boolean).join(' '))) || 'Unknown';
  return {
    bioguide: (m.id && m.id.bioguide) || null,
    name,
    party: t.party || '',
    chamber: t.type === 'sen' ? 'senate' : 'house',
    state: t.state,
    // district is only meaningful for the House; 0 == at-large / territory delegate
    district: t.type === 'rep' ? (typeof t.district === 'number' ? t.district : null) : null,
    stateRank: t.state_rank || null, // 'senior' | 'junior' for senators
    url: t.url || null,
    contactForm: t.contact_form || null,
    phone: t.phone || null,
    office: t.office || null,
    address: t.address || null,
  };
}

// The chamber-appropriate action link we send people to. Prefer the official
// contact form; fall back to the member's official site.
function contactUrl(mem) {
  return mem.contactForm || mem.url || null;
}

// States/territories with a single, at-large House seat -> the House rep is
// unambiguous from the state alone (no district needed).
const AT_LARGE = new Set(['AK', 'DE', 'ND', 'SD', 'VT', 'WY', 'DC', 'PR', 'GU', 'VI', 'AS', 'MP']);

// ---- ZIP -> state ----
function resolveZip(zip) {
  const clean = String(zip || '').trim();
  const m = clean.match(/^(\d{5})/);
  if (!m) return null;
  const prefix = parseInt(m[1].slice(0, 3), 10);
  const hit = ranges.find((r) => prefix >= r.start && prefix <= r.end);
  if (!hit) return null;
  return { zip: m[1], stateAbbr: hit.state, stateName: hit.name };
}

// ---- member lookups ----
function senatorsForState(stateAbbr) {
  return loadMembers()
    .filter((m) => m.chamber === 'senate' && m.state === stateAbbr)
    // senior first, then by name — stable, readable order
    .sort((a, b) => (a.stateRank === 'senior' ? -1 : 1) - (b.stateRank === 'senior' ? -1 : 1));
}

function houseForState(stateAbbr) {
  return loadMembers()
    .filter((m) => m.chamber === 'house' && m.state === stateAbbr)
    .sort((a, b) => (a.district || 0) - (b.district || 0));
}

function houseForDistrict(stateAbbr, district) {
  const d = Number(district);
  return loadMembers().find(
    (m) => m.chamber === 'house' && m.state === stateAbbr && m.district === d
  ) || null;
}

// Shape a member for JSON / templates.
function publicMember(mem, extra) {
  if (!mem) return null;
  return {
    bioguide: mem.bioguide,
    name: mem.name,
    party: mem.party,
    chamber: mem.chamber,
    state: mem.state,
    district: mem.district,
    stateRank: mem.stateRank,
    url: mem.url,
    contactForm: mem.contactForm,
    contactUrl: contactUrl(mem),
    phone: mem.phone,
    office: mem.office,
    address: mem.address,
    ...(extra || {}),
  };
}

// Core resolver. Given a zip (required) and optional explicit district, return
// senators (exact) + a house resolution with an honest `houseMode`:
//   'exact'      district was supplied (or at-large) -> one confirmed rep
//   'candidates' multi-district state, no district -> full delegation offered
function resolveReps({ zip, district } = {}) {
  const hit = resolveZip(zip);
  if (!hit) return null;

  const senators = senatorsForState(hit.stateAbbr).map((m) => publicMember(m));
  const delegation = houseForState(hit.stateAbbr);

  let houseMode = 'candidates';
  let houseReps = [];

  if (district !== undefined && district !== null && district !== '') {
    const rep = houseForDistrict(hit.stateAbbr, district);
    if (rep) {
      houseMode = 'exact';
      houseReps = [publicMember(rep, { resolvedBy: 'address' })];
    }
  }

  if (!houseReps.length) {
    if (AT_LARGE.has(hit.stateAbbr) && delegation.length === 1) {
      houseMode = 'exact';
      houseReps = [publicMember(delegation[0], { resolvedBy: 'at-large' })];
    } else {
      houseMode = 'candidates';
      houseReps = delegation.map((m) => publicMember(m));
    }
  }

  return {
    zip: hit.zip,
    stateAbbr: hit.stateAbbr,
    stateName: hit.stateName,
    atLarge: AT_LARGE.has(hit.stateAbbr),
    senators,
    house: { mode: houseMode, count: houseReps.length, reps: houseReps },
  };
}

// ---- free Census geocoder: full street address -> congressional district ----
// geocoding.geo.census.gov, no API key, returns the current (119th) CD.
async function geocodeAddressToDistrict(address) {
  const clean = String(address || '').trim();
  if (!clean) return { ok: false, error: 'No address provided.' };
  const url =
    'https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress' +
    '?address=' + encodeURIComponent(clean) +
    '&benchmark=Public_AR_Current&vintage=Current_Current' +
    '&layers=all&format=json';
  try {
    const res = await fetch(url, { headers: { 'User-Agent': 'petitionyour' } });
    if (!res.ok) return { ok: false, error: `Census geocoder HTTP ${res.status}` };
    const data = await res.json();
    const matches = data && data.result && data.result.addressMatches;
    if (!matches || !matches.length) {
      return { ok: false, error: 'Address not found. Check the street, city, and state.' };
    }
    const geos = matches[0].geographies || {};
    // The CD layer key is vintage-named, e.g. "119th Congressional Districts".
    const cdKey = Object.keys(geos).find((k) => /Congressional Districts?/i.test(k));
    if (!cdKey || !geos[cdKey] || !geos[cdKey].length) {
      return { ok: false, error: 'Could not determine a congressional district for that address.' };
    }
    const cd = geos[cdKey][0];
    // District number lives in a vintage field like CD119 / CD118; BASENAME is
    // the human label ("4", or "at Large"). Pull the numeric where possible.
    const cdNumRaw = Object.keys(cd).filter((k) => /^CD\d+$/.test(k)).map((k) => cd[k])[0];
    let district = null;
    if (cdNumRaw !== undefined) {
      // "98"/"99"/"00" are non-voting/at-large sentinels in Census data.
      const n = parseInt(cdNumRaw, 10);
      district = Number.isFinite(n) ? (n >= 98 ? 0 : n) : null;
    }
    const stateFips = cd.STATE || (matches[0].geographies['States'] && matches[0].geographies['States'][0] && matches[0].geographies['States'][0].STATE) || null;
    return {
      ok: true,
      district,
      districtLabel: cd.BASENAME || (district != null ? String(district) : null),
      stateFips,
      matchedAddress: matches[0].matchedAddress || clean,
    };
  } catch (err) {
    return { ok: false, error: 'Census geocoder request failed: ' + err.message };
  }
}

// Kept for backward compatibility with the existing find-reps view/API: the
// static official .gov links (always-current fallbacks / cross-checks).
function officialLinks(stateAbbr, zip) {
  return {
    findYourHouseRep: 'https://www.house.gov/representatives/find-your-representative',
    senateContactList: 'https://www.senate.gov/senators/senators-contact.htm',
    usaGovElectedOfficials: 'https://www.usa.gov/elected-officials',
    stateGovernmentSite: stateAbbr ? 'https://www.usa.gov/state-governments' : null,
    congressMemberSearch: 'https://www.congress.gov/members',
  };
}

module.exports = {
  resolveZip,
  officialLinks,
  loadMembers,
  senatorsForState,
  houseForState,
  houseForDistrict,
  resolveReps,
  geocodeAddressToDistrict,
  AT_LARGE,
};