← back to Stayclaim

scripts/ingest-pasadena-permits.ts

164 lines

/**
 * ingest-pasadena-permits.ts
 *
 * Pasadena Active Building Permits via ArcGIS FeatureServer.
 * Source: data.cityofpasadena.net item 24525f546e5e4346957bb55695afc448
 * Verified 2026-04-30: 607 rows, daily refresh.
 *
 * Tier: A (Pasadena city government primary record).
 */
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.PGHOST ?? '/tmp',
  database: process.env.PGDATABASE ?? 'stayclaim',
  user: process.env.PGUSER ?? process.env.USER,
  password: process.env.PGPASSWORD,
  port: parseInt(process.env.PGPORT ?? '5432', 10),
  max: 4,
});

const FEATURE_URL = 'https://services2.arcgis.com/zNjnZafDYCAJAbN0/ArcGIS/rest/services/Permit_Activity/FeatureServer/0/query';
const PAGE = 2000;

type Feature = {
  attributes: Record<string, any>;
  geometry?: { x: number; y: number };
};

function canonicalize(addr: string): string {
  return addr
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
    .slice(0, 90);
}

async function fetchPage(offset: number): Promise<Feature[]> {
  const params = new URLSearchParams({
    where: '1=1',
    outFields: '*',
    returnGeometry: 'true',
    outSR: '4326',
    resultOffset: String(offset),
    resultRecordCount: String(PAGE),
    f: 'json',
  });
  const res = await fetch(`${FEATURE_URL}?${params}`);
  const json = await res.json() as { features?: Feature[] };
  return json.features ?? [];
}

async function ensureSchema() {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS permit (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      source TEXT NOT NULL DEFAULT 'ladbs',
      source_dataset TEXT NOT NULL,
      permit_number TEXT NOT NULL,
      listing_id UUID REFERENCES listing(id),
      primary_address TEXT,
      zip_code TEXT,
      council_district TEXT,
      apn TEXT,
      zone TEXT,
      permit_group TEXT,
      permit_type TEXT,
      permit_sub_type TEXT,
      use_code TEXT,
      use_desc TEXT,
      issue_date DATE,
      status_date DATE,
      submitted_date DATE,
      status_desc TEXT,
      valuation NUMERIC(15,2),
      square_footage INT,
      work_desc TEXT,
      neighborhood_council TEXT,
      community_plan_area TEXT,
      latitude NUMERIC(9,6),
      longitude NUMERIC(9,6),
      source_url TEXT,
      source_tier CHAR(1) NOT NULL DEFAULT 'A',
      source_label TEXT NOT NULL DEFAULT 'LA City Department of Building & Safety',
      retrieved_at TIMESTAMPTZ DEFAULT now(),
      UNIQUE (source_dataset, permit_number)
    );
  `);
}

async function upsertOne(f: Feature): Promise<void> {
  // Pasadena fields (verified 2026-04-30): ADDRESS, CASE_NUMBER, DESCRIPTION,
  // LAND_PARCEL_NO, PARCEL_NO, LATEST_ACTIVITY (epoch ms), TOTAL_SQFT
  const a = f.attributes ?? {};
  const addr = (a.ADDRESS ?? '').toString().trim();
  if (!addr) return;
  const permitNbr = (a.CASE_NUMBER ?? '').toString().trim();
  if (!permitNbr) return;
  const canonical = canonicalize(addr);
  if (!canonical) return;

  const lat = f.geometry?.y ?? null;
  const lon = f.geometry?.x ?? null;

  const lid = (await pool.query<{ id: string }>(
    `INSERT INTO listing (slug, source, source_id, title, address_line1, city, state, country, latitude, longitude, is_public, tier)
     VALUES ($1,'pasadena_permit',$2,$3,$4,'Pasadena','CA','US',$5,$6,true,'free')
     ON CONFLICT (slug) DO UPDATE SET
       latitude = COALESCE(listing.latitude, EXCLUDED.latitude),
       longitude = COALESCE(listing.longitude, EXCLUDED.longitude),
       updated_at = now()
     RETURNING id`,
    [`${canonical}-pas`, `pasadena:${canonical}`, addr, addr, lat, lon]
  )).rows[0]!.id;

  let issueDate: string | null = null;
  if (a.LATEST_ACTIVITY) {
    const ms = parseInt(String(a.LATEST_ACTIVITY), 10);
    if (isFinite(ms) && ms > 0) {
      const d = new Date(ms);
      if (d.getFullYear() >= 1900 && d.getFullYear() <= 2030) {
        issueDate = d.toISOString().slice(0, 10);
      }
    }
  }
  await pool.query(
    `INSERT INTO permit (source, source_dataset, permit_number, listing_id, primary_address, apn,
       permit_type, permit_sub_type, status_desc, issue_date, valuation, work_desc,
       square_footage, latitude, longitude, source_url, source_label)
     VALUES ('pasadena','pasadena-permit-activity',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'City of Pasadena')
     ON CONFLICT (source_dataset, permit_number) DO UPDATE SET
       status_desc = COALESCE(EXCLUDED.status_desc, permit.status_desc),
       retrieved_at = now()`,
    [
      permitNbr, lid, addr, (a.LAND_PARCEL_NO ?? a.PARCEL_NO ?? null),
      null, null, null,
      issueDate, null, a.DESCRIPTION ?? null,
      parseInt(String(a.TOTAL_SQFT ?? '0'), 10) || null,
      lat, lon,
      `https://data.cityofpasadena.net/datasets/24525f546e5e4346957bb55695afc448_0/explore?showTable=true`,
    ]
  );
}

async function main() {
  await ensureSchema();
  let offset = 0;
  let total = 0;
  while (true) {
    const features = await fetchPage(offset);
    if (!features.length) break;
    for (const f of features) await upsertOne(f);
    total += features.length;
    console.log(`  pasadena: ${total} processed`);
    if (features.length < PAGE) break;
    offset += PAGE;
  }
  console.log(`✓ Pasadena permits: ${total} total`);
  await pool.end();
}

main().catch(e => { console.error('FATAL', e); process.exit(1); });