← back to Norma

app/api/connections/opportunities/route.ts

287 lines

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

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/connections/opportunities — Smart petition targeting opportunities

   Cross-references:
     - Critical/high urgency pulse topics
     - Politicians on relevant committees
     - District opportunity scores
     - Journalist coverage of related topics
     - Organizations in the same state

   Query params:
     ?urgency=critical     — filter pulse topics by urgency
     ?state=TX             — filter by state
     ?limit=25             — max results
   ═══════════════════════════════════════════════════════════════════════════ */

/** Pulse category -> relevant committees */
const CATEGORY_COMMITTEES: Record<string, string[]> = {
  policy: ['Education & Workforce', 'Finance', 'Health Education Labor & Pensions', 'Budget', 'Appropriations'],
  legal: ['Judiciary', 'Financial Services', 'Banking Housing & Urban Affairs'],
  civil_rights: ['Judiciary'],
  economy: ['Finance', 'Budget', 'Financial Services', 'Banking Housing & Urban Affairs'],
  education: ['Education & Workforce', 'Health Education Labor & Pensions'],
};

/** Pulse topic names -> article topic keywords for journalist matching */
const PULSE_TOPIC_KEYWORDS: Record<string, string[]> = {
  'SAVE Plan Litigation': ['SAVE Plan', 'legal challenges', 'Supreme Court', 'income-driven repayment'],
  'Federal Loan Forgiveness': ['loan forgiveness', 'Biden policy', 'congressional action', 'PSLF'],
  'Borrower Defense Claims': ['borrower defense', 'for-profit colleges', 'legal challenges'],
  'CFPB Enforcement Actions': ['debt collection', 'loan servicers', 'MOHELA'],
  'College Affordability': ['college costs', 'tuition', 'FAFSA', 'financial aid'],
  'Graduate School Debt': ['college costs', 'economic impact', 'default'],
  'Income-Driven Repayment': ['income-driven repayment', 'SAVE Plan', 'loan forgiveness'],
  'Predatory Lending': ['for-profit colleges', 'private loans', 'debt collection'],
  'Private Loan Default Rates': ['private loans', 'default', 'refinancing', 'debt collection'],
  'Racial Debt Disparities': ['equity', 'racial disparities', 'economic impact'],
  'Servicer Misconduct': ['loan servicers', 'MOHELA', 'debt collection'],
  'State-Level Borrower Protections': ['legislation', 'congressional action', 'legal challenges'],
};

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

  try {
    const { searchParams } = new URL(request.url);
    const urgencyFilter = searchParams.get('urgency');
    const stateFilter = searchParams.get('state');
    const limit = Math.min(parseInt(searchParams.get('limit') || '25', 10), 100);

    // Step 1: Get pulse topics (filtered by urgency)
    const urgencyConditions: string[] = [];
    const urgencyParams: unknown[] = [];

    if (urgencyFilter) {
      urgencyParams.push(urgencyFilter);
      urgencyConditions.push(`urgency = $${urgencyParams.length}`);
    } else {
      // Default: only critical and high
      urgencyConditions.push(`urgency IN ('critical', 'high')`);
    }

    const topicRes = await query(
      `SELECT id, name, category, urgency, description
       FROM pulse_topics
       WHERE ${urgencyConditions.join(' AND ')}
       ORDER BY
         CASE urgency WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
         name`,
      urgencyParams,
    );
    const pulseTopics = topicRes.rows;

    if (pulseTopics.length === 0) {
      return NextResponse.json({ opportunities: [], meta: { total: 0 } });
    }

    // Step 2: For each pulse topic, find relevant politicians (via mind_map_links)
    const topicIds = pulseTopics.map((t) => t.id);
    const topicPolLinks = await query(
      `SELECT m.source_id AS topic_id, m.target_id AS politician_id, m.weight, m.metadata
       FROM mind_map_links m
       WHERE m.source_type = 'pulse_topic'
         AND m.source_id = ANY($1)
         AND m.target_type = 'politician'
       ORDER BY m.weight DESC`,
      [topicIds],
    );

    // Step 3: Gather all unique politician IDs
    const polIds = [...new Set(topicPolLinks.rows.map((r) => r.politician_id))];
    if (polIds.length === 0) {
      return NextResponse.json({ opportunities: [], meta: { total: 0, reason: 'No politician-topic links found' } });
    }

    // Step 4: Fetch politician details + district info
    const polConditions: string[] = ['p.id = ANY($1)'];
    const polParams: unknown[] = [polIds];

    if (stateFilter) {
      polParams.push(stateFilter);
      polConditions.push(`p.state = $${polParams.length}`);
    }

    const polRes = await query(
      `SELECT p.id, p.name, p.party, p.state, p.committees, p.photo_url,
              p.position_student_debt,
              cd.id AS district_id, cd.state_abbr AS district_state,
              cd.district_number, cd.opportunity_score,
              cd.avg_distress_score, cd.total_population
       FROM politicians p
       LEFT JOIN congressional_districts cd ON cd.id = p.district_id
       WHERE ${polConditions.join(' AND ')}`,
      polParams,
    );

    const polMap = new Map<string, Record<string, unknown>>();
    for (const r of polRes.rows) {
      polMap.set(r.id, r);
    }

    // Step 5: Find journalist coverage for each pulse topic
    // Match journalist article topics to pulse topic keywords
    const journalistRes = await query(
      `SELECT j.id, j.name, j.outlet,
              COALESCE(
                array_agg(DISTINCT t) FILTER (WHERE t IS NOT NULL),
                '{}'
              ) AS topics,
              COUNT(DISTINCT a.id) AS article_count
       FROM journalists j
       JOIN journalist_articles a ON a.journalist_id = j.id
       LEFT JOIN LATERAL unnest(a.topics) AS t ON true
       WHERE j.is_active = true
       GROUP BY j.id, j.name, j.outlet`,
      [],
    );

    // Step 6: Find organizations by state
    const orgRes = await query(
      `SELECT id, name, type, state, key_issues FROM organizations ORDER BY name`,
      [],
    );
    const orgsByState = new Map<string, Array<Record<string, unknown>>>();
    for (const o of orgRes.rows) {
      if (!o.state) continue;
      if (!orgsByState.has(o.state)) orgsByState.set(o.state, []);
      orgsByState.get(o.state)!.push(o);
    }

    // Step 7: Build opportunities
    interface Opportunity {
      id: string;
      score: number;
      topic: Record<string, unknown>;
      politician: Record<string, unknown>;
      district: Record<string, unknown> | null;
      journalists: Array<Record<string, unknown>>;
      orgs: Array<Record<string, unknown>>;
      reasoning: string;
    }

    const opportunities: Opportunity[] = [];

    for (const link of topicPolLinks.rows) {
      const pol = polMap.get(link.politician_id);
      if (!pol) continue; // filtered by state or missing

      const topic = pulseTopics.find((t) => t.id === link.topic_id);
      if (!topic) continue;

      // Match journalists who cover relevant topics
      const topicKeywords = PULSE_TOPIC_KEYWORDS[topic.name] || [];
      const matchedJournalists: Array<Record<string, unknown>> = [];

      for (const j of journalistRes.rows) {
        const journalistTopics: string[] = j.topics || [];
        const relevantTopics = journalistTopics.filter((t: string) => topicKeywords.includes(t));
        if (relevantTopics.length > 0) {
          matchedJournalists.push({
            id: j.id,
            name: j.name,
            outlet: j.outlet,
            articleCount: parseInt(j.article_count, 10),
            relevantTopics,
          });
        }
      }

      // Find orgs in the same state
      const polState = pol.state as string;
      const stateOrgs = (polState ? orgsByState.get(polState) : []) || [];
      const limitedOrgs = stateOrgs.slice(0, 5).map((o) => ({
        name: o.name,
        type: o.type,
        state: o.state,
      }));

      // Score calculation
      const linkWeight = link.weight || 0;
      const urgencyMultiplier = topic.urgency === 'critical' ? 1.5 : topic.urgency === 'high' ? 1.2 : 1.0;
      const districtScore = pol.opportunity_score ? parseFloat(String(pol.opportunity_score)) : 0;
      const journalistBonus = matchedJournalists.length * 5;
      const orgBonus = limitedOrgs.length * 2;

      const committees = (pol.committees as string[]) || [];
      const relevantCommittees = CATEGORY_COMMITTEES[topic.category] || [];
      const committeeOverlap = committees.filter((c: string) => relevantCommittees.includes(c)).length;
      const committeeBonus = committeeOverlap * 8;

      const rawScore =
        linkWeight * 20 +
        districtScore * 0.3 +
        journalistBonus +
        orgBonus +
        committeeBonus;
      const finalScore = parseFloat((rawScore * urgencyMultiplier).toFixed(1));

      // Build reasoning
      const reasons: string[] = [];
      if (topic.urgency === 'critical') reasons.push(`${topic.name} is critical`);
      else if (topic.urgency === 'high') reasons.push(`${topic.name} is high urgency`);
      if (committeeOverlap > 0) reasons.push(`politician on ${committeeOverlap} relevant committee(s)`);
      if (districtScore > 50) reasons.push(`high-debt district (score ${districtScore.toFixed(0)})`);
      if (matchedJournalists.length > 0) reasons.push(`${matchedJournalists.length} journalist(s) covering this`);
      if (limitedOrgs.length > 0) reasons.push(`${limitedOrgs.length} org(s) in same state`);

      opportunities.push({
        id: randomUUID(),
        score: finalScore,
        topic: {
          id: topic.id,
          name: topic.name,
          urgency: topic.urgency,
          category: topic.category,
        },
        politician: {
          id: pol.id,
          name: pol.name,
          party: pol.party,
          state: pol.state,
          committees: pol.committees,
          photoUrl: pol.photo_url,
        },
        district: pol.district_id
          ? {
              state: pol.district_state || pol.state,
              district: pol.district_number,
              opportunityScore: districtScore,
            }
          : null,
        journalists: matchedJournalists.slice(0, 5),
        orgs: limitedOrgs,
        reasoning: reasons.join(' + '),
      });
    }

    // Sort by score descending, take limit
    opportunities.sort((a, b) => b.score - a.score);
    const limited = opportunities.slice(0, limit);

    return NextResponse.json({
      opportunities: limited,
      meta: {
        total: limited.length,
        totalUnfiltered: opportunities.length,
        filters: {
          urgency: urgencyFilter || 'critical,high',
          state: stateFilter || null,
        },
      },
    });
  } catch (err) {
    console.error('[connections/opportunities] Error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to compute opportunities' },
      { status: 500 },
    );
  }
}