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