← back to Norma

app/api/congress/route.ts

205 lines

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

/**
 * GET /api/congress?state=CA&party=D&search=pelosi&sort=opportunity
 *
 * Returns congress members with their districts, staffers,
 * community colleges, and opportunity data.
 *
 * Optimized: 4 queries total (members + count + batch staffers + batch colleges)
 * instead of N+1 pattern.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const sp = request.nextUrl.searchParams;
  const state = sp.get('state') || '';
  const party = sp.get('party') || '';
  const search = sp.get('search') || '';
  const districtCode = sp.get('district') || '';
  const sort = sp.get('sort') || 'state'; // 'state' | 'opportunity' | 'name' | 'debt'
  const oppLevel = sp.get('opp_level') || ''; // 'high' | 'med' | 'low'
  const limit = Math.min(Number(sp.get('limit') || 100), 500);
  const offset = Number(sp.get('offset') || 0);

  try {
    const conditions: string[] = ["p.office_level = 'federal'"];
    const params: unknown[] = [];
    let idx = 1;

    if (state) {
      conditions.push(`p.state = $${idx}`);
      params.push(state.toUpperCase());
      idx++;
    }
    if (party) {
      conditions.push(`p.party = $${idx}`);
      params.push(party.toUpperCase().substring(0, 1));
      idx++;
    }
    if (search) {
      conditions.push(`p.name ILIKE $${idx}`);
      params.push(`%${search}%`);
      idx++;
    }
    if (districtCode) {
      conditions.push(`p.congressional_district = $${idx}`);
      params.push(districtCode);
      idx++;
    }
    if (oppLevel) {
      switch (oppLevel) {
        case 'high':
          conditions.push(`cd.opportunity_score >= 70`);
          break;
        case 'med':
          conditions.push(`cd.opportunity_score >= 40 AND cd.opportunity_score < 70`);
          break;
        case 'low':
          conditions.push(`cd.opportunity_score < 40`);
          break;
      }
    }

    const where = conditions.join(' AND ');

    // Sort clause
    let orderBy: string;
    switch (sort) {
      case 'opportunity':
        orderBy = 'cd.opportunity_score DESC NULLS LAST, p.state, p.name';
        break;
      case 'name':
        orderBy = 'p.name';
        break;
      case 'debt':
        orderBy = 'cd.avg_cc_median_debt DESC NULLS LAST, p.state, p.name';
        break;
      default:
        orderBy = "p.state, (NULLIF(p.district, '')::int) NULLS LAST, p.name";
    }

    // Query 1: Main — politicians with district data
    const { rows: members } = await query(`
      SELECT
        p.id, p.name, p.title, p.party, p.state, p.district,
        p.congressional_district, p.phone, p.website, p.email,
        p.social_media, p.photo_url, p.bioguide_id,
        p.votes_with_party_pct, p.missed_votes_pct,
        p.committees, p.position_student_debt,
        cd.id as district_id,
        cd.zip_count,
        cd.community_college_count,
        cd.total_cc_enrollment,
        cd.avg_cc_median_debt,
        cd.avg_cc_pell_rate,
        cd.highest_debt_college,
        cd.total_complaints,
        cd.avg_distress_score,
        cd.opportunity_score,
        cd.opportunity_factors
      FROM politicians p
      LEFT JOIN congressional_districts cd
        ON cd.district_code = p.congressional_district AND cd.congress_number = 119
      WHERE ${where}
      ORDER BY ${orderBy}
      LIMIT $${idx} OFFSET $${idx + 1}
    `, [...params, limit, offset]);

    // Query 2: Total count
    const { rows: [{ total }] } = await query(
      `SELECT COUNT(*) as total
       FROM politicians p
       LEFT JOIN congressional_districts cd
         ON cd.district_code = p.congressional_district AND cd.congress_number = 119
       WHERE ${where}`,
      params,
    );

    // Collect IDs for batch lookups
    const memberIds = members.filter(m => m.id).map(m => m.id);
    const districtIds = [...new Set(members.filter(m => m.district_id).map(m => m.district_id))];

    // Query 3: Batch staffers for all members
    let staffersByPol: Record<string, any[]> = {};
    if (memberIds.length > 0) {
      const { rows: allStaffers } = await query(`
        SELECT id, politician_id, full_name, title, role_category, email, phone,
               office_location, is_dc_office, linkedin_url, twitter_handle,
               handles_education, handles_finance, is_key_contact
        FROM congressional_staffers
        WHERE politician_id = ANY($1::uuid[])
        ORDER BY
          CASE role_category
            WHEN 'leadership' THEN 1
            WHEN 'chief_of_staff' THEN 1
            WHEN 'legislative_director' THEN 2
            WHEN 'legislative' THEN 2
            WHEN 'communications' THEN 3
            WHEN 'constituent_services' THEN 4
            WHEN 'administrative' THEN 5
            ELSE 6
          END, full_name
      `, [memberIds]);

      for (const s of allStaffers) {
        if (!staffersByPol[s.politician_id]) staffersByPol[s.politician_id] = [];
        staffersByPol[s.politician_id].push(s);
      }
    }

    // Query 4: Batch community colleges for all districts
    let collegesByDist: Record<string, any[]> = {};
    if (districtIds.length > 0) {
      const { rows: allColleges } = await query(`
        SELECT district_id, school_name, city, state, zip, enrollment,
               median_debt, pell_grant_rate, completion_rate,
               retention_rate, tuition_in_state, avg_net_price,
               median_earnings_6yr, school_url
        FROM college_scorecard
        WHERE district_id = ANY($1::uuid[]) AND institution_type = 'community_college'
        ORDER BY enrollment DESC NULLS LAST
      `, [districtIds]);

      for (const c of allColleges) {
        if (!collegesByDist[c.district_id]) collegesByDist[c.district_id] = [];
        collegesByDist[c.district_id].push(c);
      }
    }

    // Assemble response
    const enriched = members.map(m => ({
      ...m,
      staffers: staffersByPol[m.id] || [],
      community_colleges: collegesByDist[m.district_id] || [],
    }));

    // Aggregate stats for the response header
    const stats = {
      total_members: Number(total),
      avg_opportunity_score: (() => {
        const scored = members.filter(m => m.opportunity_score != null);
        return scored.length > 0
          ? Math.round(scored.reduce((s, m) => s + Number(m.opportunity_score), 0) / scored.length * 10) / 10
          : null;
      })(),
      total_cc_enrollment: members.reduce((s, m) => s + (m.total_cc_enrollment || 0), 0),
      high_priority_count: members.filter(m => (m.opportunity_score || 0) >= 70).length,
    };

    return NextResponse.json({
      members: enriched,
      total: Number(total),
      stats,
      limit,
      offset,
    });
  } catch (err) {
    console.error('[congress] API error:', err);
    return NextResponse.json({ error: (err as Error).message }, { status: 500 });
  }
}