← back to Norma

app/api/geo/report-card/route.ts

149 lines

/**
 * GET /api/geo/report-card?zip=12345
 *
 * Returns a comprehensive "report card" for a single zip code:
 *   - zip_profiles row (composite scores)
 *   - Associated geo_hotspots
 *   - Top CFPB complaints for that zip
 *   - Nearby schools (same state) from college_scorecard
 *   - Community posts (matched by state)
 *   - HEND resources mentioning that state
 *
 * Requires authentication.
 */

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

export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { searchParams } = new URL(request.url);
  const zip = searchParams.get('zip');

  if (!zip || !/^\d{5}$/.test(zip)) {
    return NextResponse.json(
      { error: 'Valid 5-digit zip parameter is required' },
      { status: 400 },
    );
  }

  try {
    // ── Fetch zip profile ──
    const { rows: profileRows } = await query(
      `SELECT * FROM zip_profiles WHERE zip = $1`,
      [zip],
    );

    const profile = profileRows[0] || null;

    // Get state for related queries
    const stateAbbr = profile?.state || null;

    // If no profile exists, still try to gather raw data
    // ── Run all related queries in parallel ──
    const [hotspotsRes, complaintsRes, schoolsRes, communityRes, hendRes] = await Promise.all([
      // Hotspots for this zip
      query(
        `SELECT id, hotspot_type, z_score, rank, severity, contributing_factors, detected_at
         FROM geo_hotspots
         WHERE zip = $1
         ORDER BY z_score DESC`,
        [zip],
      ),

      // CFPB complaints for this zip
      query(
        `SELECT complaint_id, date_received, product, sub_product, issue, sub_issue,
                company, company_response, state, consumer_disputed, submitted_via,
                complaint_narrative, tags
         FROM cfpb_complaints
         WHERE zip_code = $1
         ORDER BY date_received DESC
         LIMIT 20`,
        [zip],
      ),

      // Schools in the same state
      stateAbbr
        ? query(
            `SELECT school_name, city, state, zip, ownership, tuition_in_state,
                    tuition_out_state, avg_net_price, pell_grant_rate, federal_loan_rate,
                    median_debt, completion_rate, median_earnings_6yr, enrollment
             FROM college_scorecard
             WHERE state = $1
             ORDER BY median_debt DESC NULLS LAST
             LIMIT 10`,
            [stateAbbr],
          )
        : Promise.resolve({ rows: [] }),

      // Community posts for the state (matched via body/title text search or sentiment)
      stateAbbr
        ? query(
            `SELECT id, platform, subreddit, author, title, body, url, score,
                    num_comments, posted_at, sentiment, debt_amount, school_mentioned, tags
             FROM community_posts
             ORDER BY posted_at DESC NULLS LAST
             LIMIT 10`,
          )
        : Promise.resolve({ rows: [] }),

      // HEND resources mentioning this state
      stateAbbr
        ? query(
            `SELECT id, title, url, source_type, published_date, author, organization,
                    summary, key_findings, states_mentioned, policy_topics
             FROM hend_resources
             WHERE states_mentioned @> ARRAY[$1]
             ORDER BY published_date DESC NULLS LAST
             LIMIT 10`,
            [stateAbbr],
          )
        : Promise.resolve({ rows: [] }),
    ]);

    // ── Compute letter grade from distress score ──
    const distressScore = Number(profile?.debt_distress_score ?? 0);
    let grade = 'N/A';
    if (profile) {
      if (distressScore >= 80) grade = 'F';
      else if (distressScore >= 60) grade = 'D';
      else if (distressScore >= 40) grade = 'C';
      else if (distressScore >= 20) grade = 'B';
      else grade = 'A';
    }

    return NextResponse.json({
      zip,
      grade,
      profile,
      hotspots: hotspotsRes.rows,
      complaints: {
        count: complaintsRes.rows.length,
        items: complaintsRes.rows,
      },
      schools: {
        count: schoolsRes.rows.length,
        items: schoolsRes.rows,
      },
      community_posts: {
        count: communityRes.rows.length,
        items: communityRes.rows,
      },
      hend_resources: {
        count: hendRes.rows.length,
        items: hendRes.rows,
      },
    });
  } catch (err) {
    console.error('[geo/report-card] Error:', (err as Error).message);
    return NextResponse.json(
      { error: `Failed to generate report card: ${(err as Error).message}` },
      { status: 500 },
    );
  }
}