← back to Norma

app/api/geo/crosswalk/route.ts

182 lines

/**
 * POST /api/geo/crosswalk
 *   Downloads the Census ZCTA-to-County relationship file and inserts every
 *   ZIP+county pair into the `zip_crosswalk` table.
 *   Uses ON CONFLICT DO NOTHING so re-running is safe.
 *
 * GET /api/geo/crosswalk
 *   Returns aggregate stats about the crosswalk data currently in the DB.
 */

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { downloadCrosswalk, type CrosswalkRow } from '@/lib/geo-crosswalk';

/* ─── POST: Ingest crosswalk file into zip_crosswalk table ────────── */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  const startTime = Date.now();

  // 1. Download and parse the Census file
  let rows: CrosswalkRow[];
  try {
    rows = await downloadCrosswalk();
  } catch (err) {
    // downloadCrosswalk() never throws — it returns [] on error —
    // but guard just in case.
    console.error('[geo/crosswalk] Unexpected download error:', (err as Error).message);
    return NextResponse.json(
      { success: false, error: 'Failed to download crosswalk file' },
      { status: 502 },
    );
  }

  if (rows.length === 0) {
    return NextResponse.json(
      {
        success: false,
        error:
          'Crosswalk file returned no records. The Census URL may be unavailable or the file format may have changed.',
      },
      { status: 502 },
    );
  }

  // 2. Batch-insert into zip_crosswalk
  let inserted = 0;
  let skipped  = 0;
  const errors: string[] = [];

  // Process in chunks of 500 to avoid overwhelming the connection pool.
  const CHUNK_SIZE = 500;

  for (let offset = 0; offset < rows.length; offset += CHUNK_SIZE) {
    const chunk = rows.slice(offset, offset + CHUNK_SIZE);

    for (const row of chunk) {
      try {
        const result = await query(
          `INSERT INTO zip_crosswalk (zip, county_fips, state_fips, state_abbr, county_name)
           VALUES ($1, $2, $3, $4, $5)
           ON CONFLICT (zip, county_fips) DO NOTHING`,
          [row.zip, row.countyFips, row.stateFips, row.stateAbbr, row.countyName],
        );

        // rowCount === 1 means a new row was inserted; 0 means ON CONFLICT hit.
        if ((result.rowCount ?? 0) > 0) {
          inserted++;
        } else {
          skipped++;
        }
      } catch (err) {
        const msg = `zip=${row.zip} county=${row.countyFips}: ${(err as Error).message}`;
        errors.push(msg);
        // Log the first 10 errors in detail; after that just count them.
        if (errors.length <= 10) {
          console.error('[geo/crosswalk] Insert error:', msg);
        }
      }
    }
  }

  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
  const status  = errors.length === 0 ? 'success' : errors.length < rows.length ? 'partial' : 'failed';

  return NextResponse.json({
    success: status !== 'failed',
    status,
    elapsed_seconds: elapsed,
    total_parsed:  rows.length,
    inserted,
    skipped,
    error_count:   errors.length,
    // Return up to 20 sample errors so the caller can debug without flooding
    sample_errors: errors.slice(0, 20),
  });
}

/* ─── GET: Return crosswalk stats from the database ──────────────────── */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  try {
    // Run all stat queries in parallel
    const [totalResult, stateResult, sampleResult, multiCountyResult] =
      await Promise.all([
        // Total row count and unique zip count
        query<{ total_rows: string; unique_zips: string; unique_counties: string; unique_states: string }>(
          `SELECT
             COUNT(*)                        AS total_rows,
             COUNT(DISTINCT zip)             AS unique_zips,
             COUNT(DISTINCT county_fips)     AS unique_counties,
             COUNT(DISTINCT state_fips)      AS unique_states
           FROM zip_crosswalk`,
        ),

        // Rows per state, ordered by volume
        query<{ state_abbr: string; state_fips: string; zip_count: string; county_count: string }>(
          `SELECT
             state_abbr,
             state_fips,
             COUNT(DISTINCT zip)         AS zip_count,
             COUNT(DISTINCT county_fips) AS county_count
           FROM zip_crosswalk
           GROUP BY state_abbr, state_fips
           ORDER BY zip_count DESC`,
        ),

        // 5 sample rows for quick sanity check
        query<{
          zip: string;
          county_fips: string;
          county_name: string;
          state_abbr: string;
        }>(
          `SELECT zip, county_fips, county_name, state_abbr
           FROM zip_crosswalk
           ORDER BY zip
           LIMIT 5`,
        ),

        // How many ZIPs span more than one county
        query<{ multi_county_zips: string }>(
          `SELECT COUNT(*) AS multi_county_zips
           FROM (
             SELECT zip
             FROM zip_crosswalk
             GROUP BY zip
             HAVING COUNT(*) > 1
           ) t`,
        ),
      ]);

    const totals       = totalResult.rows[0];
    const multiCounty  = multiCountyResult.rows[0];

    return NextResponse.json({
      totals: {
        total_rows:      Number(totals?.total_rows      ?? 0),
        unique_zips:     Number(totals?.unique_zips     ?? 0),
        unique_counties: Number(totals?.unique_counties ?? 0),
        unique_states:   Number(totals?.unique_states   ?? 0),
        multi_county_zips: Number(multiCounty?.multi_county_zips ?? 0),
      },
      by_state: stateResult.rows.map((r) => ({
        state_abbr:   r.state_abbr,
        state_fips:   r.state_fips,
        zip_count:    Number(r.zip_count),
        county_count: Number(r.county_count),
      })),
      sample_rows: sampleResult.rows,
    });
  } catch (err) {
    console.error('[geo/crosswalk] GET error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to fetch crosswalk stats' },
      { status: 500 },
    );
  }
}