← 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 },
);
}
}