← back to Norma

app/api/connections/power-map/route.ts

219 lines

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

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/connections/power-map — Politicians ranked by connection density

   Query params:
     ?party=D          — filter by party
     ?state=NY         — filter by state
     ?committee=Education  — filter by committee (partial match)
     ?limit=50         — max results

   Score formula:
     orgConnections:    count of org links * 2
     pressCoverage:     count of journalist links * 3
     topicRelevance:    count of pulse_topic links * 2
     districtDistress:  opportunity_score from congressional_districts
     committeePower:    5 pts for Education/HELP/Financial Services,
                        3 pts for Finance/Judiciary/Budget, 1 for others
     totalScore:        sum, normalized to 0-100
   ═══════════════════════════════════════════════════════════════════════════ */

const HIGH_POWER_COMMITTEES = [
  'Education & Workforce',
  'Health Education Labor & Pensions',
  'Financial Services',
];

const MID_POWER_COMMITTEES = ['Finance', 'Judiciary', 'Budget'];

function computeCommitteePower(committees: string[]): number {
  let power = 0;
  for (const c of committees) {
    if (HIGH_POWER_COMMITTEES.includes(c)) power += 5;
    else if (MID_POWER_COMMITTEES.includes(c)) power += 3;
    else power += 1;
  }
  return power;
}

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 party = searchParams.get('party');
    const state = searchParams.get('state');
    const committee = searchParams.get('committee');
    const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 500);

    // Build politician filter
    const conditions: string[] = [];
    const params: unknown[] = [];

    if (party) {
      params.push(party);
      conditions.push(`p.party = $${params.length}`);
    }
    if (state) {
      params.push(state);
      conditions.push(`p.state = $${params.length}`);
    }
    if (committee) {
      params.push(`%${committee}%`);
      conditions.push(`EXISTS (SELECT 1 FROM unnest(p.committees) AS c WHERE c ILIKE $${params.length})`);
    }

    const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    // Fetch politicians with their connection counts in a single query
    const polRes = await query(
      `SELECT
         p.id, p.name, p.party, p.state, p.committees, p.photo_url,
         p.position_student_debt,
         -- Connection counts from mind_map_links
         COALESCE(org_links.cnt, 0)::int AS org_count,
         COALESCE(press_links.cnt, 0)::int AS press_count,
         COALESCE(topic_links.cnt, 0)::int AS topic_count,
         -- District opportunity score (via district link or direct join)
         cd.opportunity_score
       FROM politicians p
       LEFT JOIN (
         SELECT target_id AS pid, COUNT(*) AS cnt
         FROM mind_map_links
         WHERE target_type = 'politician' AND source_type = 'organization'
         GROUP BY target_id
         UNION ALL
         SELECT source_id AS pid, COUNT(*) AS cnt
         FROM mind_map_links
         WHERE source_type = 'politician' AND target_type = 'organization'
         GROUP BY source_id
       ) org_links ON org_links.pid = p.id
       LEFT JOIN (
         SELECT target_id AS pid, COUNT(*) AS cnt
         FROM mind_map_links
         WHERE target_type = 'politician' AND source_type = 'journalist'
         GROUP BY target_id
       ) press_links ON press_links.pid = p.id
       LEFT JOIN (
         SELECT target_id AS pid, COUNT(*) AS cnt
         FROM mind_map_links
         WHERE target_type = 'politician' AND source_type = 'pulse_topic'
         GROUP BY target_id
       ) topic_links ON topic_links.pid = p.id
       LEFT JOIN congressional_districts cd ON cd.id = p.district_id
       ${where}
       ORDER BY p.name`,
      params,
    );

    // Compute scores
    const scored = polRes.rows.map((r) => {
      const committees = r.committees || [];
      const orgConnections = (r.org_count || 0) * 2;
      const pressCoverage = (r.press_count || 0) * 3;
      const topicRelevance = (r.topic_count || 0) * 2;
      const districtDistress = r.opportunity_score ? parseFloat(r.opportunity_score) : 0;
      const committeePower = computeCommitteePower(committees);
      const rawTotal = orgConnections + pressCoverage + topicRelevance + districtDistress + committeePower;

      return {
        politician: {
          id: r.id,
          name: r.name,
          party: r.party,
          state: r.state,
          committees,
          photoUrl: r.photo_url,
          positionStudentDebt: r.position_student_debt,
        },
        scores: {
          orgConnections,
          pressCoverage,
          topicRelevance,
          districtDistress: parseFloat(districtDistress.toFixed(1)),
          committeePower,
          rawTotal: parseFloat(rawTotal.toFixed(1)),
          totalScore: 0, // normalized below
        },
        connectionCounts: {
          organizations: r.org_count || 0,
          journalists: r.press_count || 0,
          pulseTopics: r.topic_count || 0,
        },
      };
    });

    // Normalize to 0-100
    const maxRaw = Math.max(...scored.map((s) => s.scores.rawTotal), 1);
    for (const s of scored) {
      s.scores.totalScore = parseFloat(((s.scores.rawTotal / maxRaw) * 100).toFixed(1));
    }

    // Sort by total score descending and take limit
    scored.sort((a, b) => b.scores.totalScore - a.scores.totalScore);
    const rankings = scored.slice(0, limit);

    // Fetch top connections for each ranked politician (top 5)
    const topPolIds = rankings.slice(0, 20).map((r) => r.politician.id);
    let topConnectionsMap = new Map<string, Array<Record<string, unknown>>>();

    if (topPolIds.length > 0) {
      const connRes = await query(
        `SELECT m.source_type, m.source_id, m.target_type, m.target_id, m.link_type, m.weight,
                CASE
                  WHEN m.target_type = 'politician' THEN m.source_type
                  ELSE m.target_type
                END AS connected_type,
                CASE
                  WHEN m.target_type = 'politician' THEN m.source_id
                  ELSE m.target_id
                END AS connected_id
         FROM mind_map_links m
         WHERE (m.target_type = 'politician' AND m.target_id = ANY($1))
            OR (m.source_type = 'politician' AND m.source_id = ANY($1))
         ORDER BY m.weight DESC`,
        [topPolIds],
      );

      // Group by politician
      for (const row of connRes.rows) {
        const polId = row.target_type === 'politician' ? row.target_id : row.source_id;
        if (!topConnectionsMap.has(polId)) topConnectionsMap.set(polId, []);
        const arr = topConnectionsMap.get(polId)!;
        if (arr.length < 5) {
          arr.push({
            type: row.connected_type,
            id: row.connected_id,
            linkType: row.link_type,
            weight: row.weight,
          });
        }
      }
    }

    // Attach top connections
    for (const r of rankings) {
      (r as Record<string, unknown>).topConnections = topConnectionsMap.get(r.politician.id) || [];
    }

    return NextResponse.json({
      rankings,
      meta: {
        total: rankings.length,
        maxRawScore: parseFloat(maxRaw.toFixed(1)),
        filters: { party, state, committee },
      },
    });
  } catch (err) {
    console.error('[connections/power-map] Error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to compute power map' },
      { status: 500 },
    );
  }
}