← back to Norma

app/api/connections/graph/route.ts

207 lines

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

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/connections/graph — D3-compatible force graph data

   Query params:
     ?types=politician,organization   — filter node types (comma-separated)
     ?minWeight=0.3                   — filter out weak links
     ?state=NY                        — filter by state
     ?limit=200                       — max nodes returned
   ═══════════════════════════════════════════════════════════════════════════ */

const TYPE_GROUP: Record<string, number> = {
  politician: 1,
  organization: 2,
  journalist: 3,
  pulse_topic: 4,
  district: 5,
};

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 typesParam = searchParams.get('types');
    const minWeight = parseFloat(searchParams.get('minWeight') || '0');
    const state = searchParams.get('state');
    const limit = Math.min(parseInt(searchParams.get('limit') || '200', 10), 1000);

    const allowedTypes = typesParam
      ? typesParam.split(',').map((t) => t.trim()).filter(Boolean)
      : ['politician', 'organization', 'journalist', 'pulse_topic'];

    // Build node queries for each requested type
    const nodes: Array<{
      id: string;
      type: string;
      label: string;
      group: number;
      [key: string]: unknown;
    }> = [];

    // Track collected node IDs for link filtering
    const nodeIds = new Set<string>();

    // Politicians
    if (allowedTypes.includes('politician')) {
      const conditions: string[] = [];
      const params: unknown[] = [];
      if (state) {
        params.push(state);
        conditions.push(`state = $${params.length}`);
      }
      const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
      const res = await query(
        `SELECT id, name, party, state, committees FROM politicians ${where} ORDER BY name LIMIT $${params.length + 1}`,
        [...params, limit],
      );
      for (const r of res.rows) {
        nodes.push({
          id: r.id,
          type: 'politician',
          label: r.name,
          party: r.party,
          state: r.state,
          committees: r.committees || [],
          group: TYPE_GROUP.politician,
        });
        nodeIds.add(r.id);
      }
    }

    // Organizations
    if (allowedTypes.includes('organization')) {
      const conditions: string[] = [];
      const params: unknown[] = [];
      if (state) {
        params.push(state);
        conditions.push(`state = $${params.length}`);
      }
      const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
      const res = await query(
        `SELECT id, name, type, category, state FROM organizations ${where} ORDER BY name LIMIT $${params.length + 1}`,
        [...params, limit],
      );
      for (const r of res.rows) {
        nodes.push({
          id: r.id,
          type: 'organization',
          label: r.name,
          category: r.category || r.type,
          state: r.state,
          group: TYPE_GROUP.organization,
        });
        nodeIds.add(r.id);
      }
    }

    // Journalists
    if (allowedTypes.includes('journalist')) {
      const res = await query(
        `SELECT id, name, outlet FROM journalists WHERE is_active = true ORDER BY name LIMIT $1`,
        [limit],
      );
      for (const r of res.rows) {
        nodes.push({
          id: r.id,
          type: 'journalist',
          label: r.name,
          outlet: r.outlet,
          group: TYPE_GROUP.journalist,
        });
        nodeIds.add(r.id);
      }
    }

    // Pulse Topics
    if (allowedTypes.includes('pulse_topic')) {
      const res = await query(
        `SELECT id, name, category, urgency FROM pulse_topics ORDER BY urgency DESC, name LIMIT $1`,
        [limit],
      );
      for (const r of res.rows) {
        nodes.push({
          id: r.id,
          type: 'pulse_topic',
          label: r.name,
          urgency: r.urgency,
          category: r.category,
          group: TYPE_GROUP.pulse_topic,
        });
        nodeIds.add(r.id);
      }
    }

    // Districts (only if explicitly requested)
    if (allowedTypes.includes('district')) {
      const conditions: string[] = [];
      const params: unknown[] = [];
      if (state) {
        params.push(state);
        conditions.push(`state_abbr = $${params.length}`);
      }
      const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
      const res = await query(
        `SELECT id, state_abbr, district_number, representative_name, opportunity_score
         FROM congressional_districts ${where}
         ORDER BY opportunity_score DESC NULLS LAST LIMIT $${params.length + 1}`,
        [...params, limit],
      );
      for (const r of res.rows) {
        nodes.push({
          id: r.id,
          type: 'district',
          label: `${r.state_abbr}-${r.district_number}`,
          state: r.state_abbr,
          representative: r.representative_name,
          opportunityScore: r.opportunity_score ? parseFloat(r.opportunity_score) : null,
          group: TYPE_GROUP.district,
        });
        nodeIds.add(r.id);
      }
    }

    // Fetch links from mind_map_links that connect our collected nodes
    const linkParams: unknown[] = [minWeight];
    const res = await query(
      `SELECT source_type, source_id, target_type, target_id, link_type, weight
       FROM mind_map_links
       WHERE weight >= $1`,
      linkParams,
    );

    // Filter links to only include those where both endpoints are in our node set
    const links = res.rows
      .filter((r) => nodeIds.has(r.source_id) && nodeIds.has(r.target_id))
      .map((r) => ({
        source: r.source_id,
        target: r.target_id,
        type: r.link_type,
        weight: r.weight,
      }));

    return NextResponse.json({
      nodes,
      links,
      meta: {
        nodeCount: nodes.length,
        linkCount: links.length,
        types: allowedTypes,
        minWeight,
        state: state || null,
      },
    });
  } catch (err) {
    console.error('[connections/graph] Error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to load graph data' },
      { status: 500 },
    );
  }
}