← back to Norma

app/api/connections/entity/[type]/[id]/route.ts

251 lines

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

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/connections/entity/[type]/[id]

   Returns all connections for a specific entity.
   Type must be: politician, organization, journalist, pulse_topic, district

   Response:
   {
     entity: { id, type, name, ...details },
     connections: [{ entity: {...}, linkType, weight, metadata }],
     stats: { totalConnections, byType: {organization: 8, journalist: 5} }
   }
   ═══════════════════════════════════════════════════════════════════════════ */

const VALID_TYPES = ['politician', 'organization', 'journalist', 'pulse_topic', 'district'];

// Fetch entity details by type
async function fetchEntity(type: string, id: string): Promise<Record<string, unknown> | null> {
  switch (type) {
    case 'politician': {
      const res = await query(
        `SELECT id, name, title, party, state, district, committees, caucuses,
                position_student_debt, position_education, phone, email, website, photo_url
         FROM politicians WHERE id = $1`,
        [id],
      );
      return res.rows[0] ? { ...res.rows[0], type: 'politician' } : null;
    }
    case 'organization': {
      const res = await query(
        `SELECT id, name, type, category, state, city, key_issues, political_leaning,
                website, description, mission, annual_revenue, staff_size
         FROM organizations WHERE id = $1`,
        [id],
      );
      return res.rows[0] ? { ...res.rows[0], type: 'organization' } : null;
    }
    case 'journalist': {
      const res = await query(
        `SELECT j.id, j.name, j.outlet, j.beat, j.bio, j.twitter_handle, j.email, j.tags,
                COUNT(a.id) AS article_count
         FROM journalists j
         LEFT JOIN journalist_articles a ON a.journalist_id = j.id
         WHERE j.id = $1
         GROUP BY j.id`,
        [id],
      );
      return res.rows[0] ? { ...res.rows[0], type: 'journalist' } : null;
    }
    case 'pulse_topic': {
      const res = await query(
        `SELECT id, name, category, urgency, description FROM pulse_topics WHERE id = $1`,
        [id],
      );
      return res.rows[0] ? { ...res.rows[0], type: 'pulse_topic' } : null;
    }
    case 'district': {
      const res = await query(
        `SELECT id, state_abbr, district_number, representative_name, representative_party,
                opportunity_score, total_population, avg_distress_score,
                community_college_count, total_cc_enrollment
         FROM congressional_districts WHERE id = $1`,
        [id],
      );
      return res.rows[0] ? { ...res.rows[0], type: 'district' } : null;
    }
    default:
      return null;
  }
}

// Fetch connected entity details in bulk
async function fetchConnectedEntities(
  entities: Array<{ type: string; id: string }>,
): Promise<Map<string, Record<string, unknown>>> {
  const map = new Map<string, Record<string, unknown>>();
  if (entities.length === 0) return map;

  // Group by type
  const byType: Record<string, string[]> = {};
  for (const e of entities) {
    if (!byType[e.type]) byType[e.type] = [];
    byType[e.type].push(e.id);
  }

  const queries: Promise<void>[] = [];

  if (byType.politician?.length) {
    queries.push(
      query(
        `SELECT id, name, party, state, committees, photo_url FROM politicians WHERE id = ANY($1)`,
        [byType.politician],
      ).then((res) => {
        for (const r of res.rows) map.set(r.id, { ...r, type: 'politician' });
      }),
    );
  }

  if (byType.organization?.length) {
    queries.push(
      query(
        `SELECT id, name, type, category, state, key_issues FROM organizations WHERE id = ANY($1)`,
        [byType.organization],
      ).then((res) => {
        for (const r of res.rows) map.set(r.id, { ...r, type: 'organization' });
      }),
    );
  }

  if (byType.journalist?.length) {
    queries.push(
      query(
        `SELECT id, name, outlet, beat FROM journalists WHERE id = ANY($1)`,
        [byType.journalist],
      ).then((res) => {
        for (const r of res.rows) map.set(r.id, { ...r, type: 'journalist' });
      }),
    );
  }

  if (byType.pulse_topic?.length) {
    queries.push(
      query(
        `SELECT id, name, category, urgency FROM pulse_topics WHERE id = ANY($1)`,
        [byType.pulse_topic],
      ).then((res) => {
        for (const r of res.rows) map.set(r.id, { ...r, type: 'pulse_topic' });
      }),
    );
  }

  if (byType.district?.length) {
    queries.push(
      query(
        `SELECT id, state_abbr, district_number, representative_name, opportunity_score
         FROM congressional_districts WHERE id = ANY($1)`,
        [byType.district],
      ).then((res) => {
        for (const r of res.rows) map.set(r.id, { ...r, type: 'district', label: `${r.state_abbr}-${r.district_number}` });
      }),
    );
  }

  await Promise.all(queries);
  return map;
}

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ type: string; id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { type, id } = await params;

  if (!VALID_TYPES.includes(type)) {
    return NextResponse.json(
      { error: `Invalid entity type: ${type}. Must be one of: ${VALID_TYPES.join(', ')}` },
      { status: 400 },
    );
  }

  // Validate UUID format
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  if (!uuidRegex.test(id)) {
    return NextResponse.json({ error: 'Invalid entity ID format' }, { status: 400 });
  }

  try {
    // Fetch the main entity
    const entity = await fetchEntity(type, id);
    if (!entity) {
      return NextResponse.json({ error: 'Entity not found' }, { status: 404 });
    }

    // Fetch all links where this entity is source or target
    // Use source_label/target_label for fast label resolution
    const linksResult = await query(
      `SELECT source_type, source_id, source_label, target_type, target_id, target_label,
              link_type, weight, metadata
       FROM mind_map_links
       WHERE (source_type = $1 AND source_id = $2)
          OR (target_type = $1 AND target_id = $2)
       ORDER BY weight DESC
       LIMIT 500`,
      [type, id],
    );

    // Collect connected entity refs (for enriched lookups)
    const connectedRefs: Array<{ type: string; id: string }> = [];
    for (const link of linksResult.rows) {
      if (link.source_id === id) {
        connectedRefs.push({ type: link.target_type, id: link.target_id });
      } else {
        connectedRefs.push({ type: link.source_type, id: link.source_id });
      }
    }

    // Bulk fetch connected entity details (for types we have enriched queries for)
    const entityMap = await fetchConnectedEntities(connectedRefs);

    // Build connections response — use entityMap for enriched data, fall back to link labels
    const connections = linksResult.rows.map((link) => {
      const isSource = link.source_id === id;
      const connectedId = isSource ? link.target_id : link.source_id;
      const connectedType = isSource ? link.target_type : link.source_type;
      const connectedLabel = isSource ? (link.target_label || '') : (link.source_label || '');
      const connectedEntity = entityMap.get(connectedId) || {
        id: connectedId,
        type: connectedType,
        label: connectedLabel || connectedType,
        name: connectedLabel || connectedType,
      };

      return {
        entity: connectedEntity,
        linkType: link.link_type,
        weight: link.weight,
        metadata: link.metadata || {},
      };
    });

    // Build stats
    const byType: Record<string, number> = {};
    for (const c of connections) {
      const t = (c.entity as Record<string, unknown>).type as string;
      byType[t] = (byType[t] || 0) + 1;
    }

    return NextResponse.json({
      entity,
      connections,
      stats: {
        totalConnections: connections.length,
        byType,
      },
    });
  } catch (err) {
    console.error('[connections/entity] Error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to load entity connections' },
      { status: 500 },
    );
  }
}