← back to Norma
app/api/connections/opportunities/route.ts
287 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { randomUUID } from 'crypto';
/* ═══════════════════════════════════════════════════════════════════════════
GET /api/connections/opportunities — Smart petition targeting opportunities
Cross-references:
- Critical/high urgency pulse topics
- Politicians on relevant committees
- District opportunity scores
- Journalist coverage of related topics
- Organizations in the same state
Query params:
?urgency=critical — filter pulse topics by urgency
?state=TX — filter by state
?limit=25 — max results
═══════════════════════════════════════════════════════════════════════════ */
/** Pulse category -> relevant committees */
const CATEGORY_COMMITTEES: Record<string, string[]> = {
policy: ['Education & Workforce', 'Finance', 'Health Education Labor & Pensions', 'Budget', 'Appropriations'],
legal: ['Judiciary', 'Financial Services', 'Banking Housing & Urban Affairs'],
civil_rights: ['Judiciary'],
economy: ['Finance', 'Budget', 'Financial Services', 'Banking Housing & Urban Affairs'],
education: ['Education & Workforce', 'Health Education Labor & Pensions'],
};
/** Pulse topic names -> article topic keywords for journalist matching */
const PULSE_TOPIC_KEYWORDS: Record<string, string[]> = {
'SAVE Plan Litigation': ['SAVE Plan', 'legal challenges', 'Supreme Court', 'income-driven repayment'],
'Federal Loan Forgiveness': ['loan forgiveness', 'Biden policy', 'congressional action', 'PSLF'],
'Borrower Defense Claims': ['borrower defense', 'for-profit colleges', 'legal challenges'],
'CFPB Enforcement Actions': ['debt collection', 'loan servicers', 'MOHELA'],
'College Affordability': ['college costs', 'tuition', 'FAFSA', 'financial aid'],
'Graduate School Debt': ['college costs', 'economic impact', 'default'],
'Income-Driven Repayment': ['income-driven repayment', 'SAVE Plan', 'loan forgiveness'],
'Predatory Lending': ['for-profit colleges', 'private loans', 'debt collection'],
'Private Loan Default Rates': ['private loans', 'default', 'refinancing', 'debt collection'],
'Racial Debt Disparities': ['equity', 'racial disparities', 'economic impact'],
'Servicer Misconduct': ['loan servicers', 'MOHELA', 'debt collection'],
'State-Level Borrower Protections': ['legislation', 'congressional action', 'legal challenges'],
};
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 urgencyFilter = searchParams.get('urgency');
const stateFilter = searchParams.get('state');
const limit = Math.min(parseInt(searchParams.get('limit') || '25', 10), 100);
// Step 1: Get pulse topics (filtered by urgency)
const urgencyConditions: string[] = [];
const urgencyParams: unknown[] = [];
if (urgencyFilter) {
urgencyParams.push(urgencyFilter);
urgencyConditions.push(`urgency = $${urgencyParams.length}`);
} else {
// Default: only critical and high
urgencyConditions.push(`urgency IN ('critical', 'high')`);
}
const topicRes = await query(
`SELECT id, name, category, urgency, description
FROM pulse_topics
WHERE ${urgencyConditions.join(' AND ')}
ORDER BY
CASE urgency WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
name`,
urgencyParams,
);
const pulseTopics = topicRes.rows;
if (pulseTopics.length === 0) {
return NextResponse.json({ opportunities: [], meta: { total: 0 } });
}
// Step 2: For each pulse topic, find relevant politicians (via mind_map_links)
const topicIds = pulseTopics.map((t) => t.id);
const topicPolLinks = await query(
`SELECT m.source_id AS topic_id, m.target_id AS politician_id, m.weight, m.metadata
FROM mind_map_links m
WHERE m.source_type = 'pulse_topic'
AND m.source_id = ANY($1)
AND m.target_type = 'politician'
ORDER BY m.weight DESC`,
[topicIds],
);
// Step 3: Gather all unique politician IDs
const polIds = [...new Set(topicPolLinks.rows.map((r) => r.politician_id))];
if (polIds.length === 0) {
return NextResponse.json({ opportunities: [], meta: { total: 0, reason: 'No politician-topic links found' } });
}
// Step 4: Fetch politician details + district info
const polConditions: string[] = ['p.id = ANY($1)'];
const polParams: unknown[] = [polIds];
if (stateFilter) {
polParams.push(stateFilter);
polConditions.push(`p.state = $${polParams.length}`);
}
const polRes = await query(
`SELECT p.id, p.name, p.party, p.state, p.committees, p.photo_url,
p.position_student_debt,
cd.id AS district_id, cd.state_abbr AS district_state,
cd.district_number, cd.opportunity_score,
cd.avg_distress_score, cd.total_population
FROM politicians p
LEFT JOIN congressional_districts cd ON cd.id = p.district_id
WHERE ${polConditions.join(' AND ')}`,
polParams,
);
const polMap = new Map<string, Record<string, unknown>>();
for (const r of polRes.rows) {
polMap.set(r.id, r);
}
// Step 5: Find journalist coverage for each pulse topic
// Match journalist article topics to pulse topic keywords
const journalistRes = await query(
`SELECT j.id, j.name, j.outlet,
COALESCE(
array_agg(DISTINCT t) FILTER (WHERE t IS NOT NULL),
'{}'
) AS topics,
COUNT(DISTINCT a.id) AS article_count
FROM journalists j
JOIN journalist_articles a ON a.journalist_id = j.id
LEFT JOIN LATERAL unnest(a.topics) AS t ON true
WHERE j.is_active = true
GROUP BY j.id, j.name, j.outlet`,
[],
);
// Step 6: Find organizations by state
const orgRes = await query(
`SELECT id, name, type, state, key_issues FROM organizations ORDER BY name`,
[],
);
const orgsByState = new Map<string, Array<Record<string, unknown>>>();
for (const o of orgRes.rows) {
if (!o.state) continue;
if (!orgsByState.has(o.state)) orgsByState.set(o.state, []);
orgsByState.get(o.state)!.push(o);
}
// Step 7: Build opportunities
interface Opportunity {
id: string;
score: number;
topic: Record<string, unknown>;
politician: Record<string, unknown>;
district: Record<string, unknown> | null;
journalists: Array<Record<string, unknown>>;
orgs: Array<Record<string, unknown>>;
reasoning: string;
}
const opportunities: Opportunity[] = [];
for (const link of topicPolLinks.rows) {
const pol = polMap.get(link.politician_id);
if (!pol) continue; // filtered by state or missing
const topic = pulseTopics.find((t) => t.id === link.topic_id);
if (!topic) continue;
// Match journalists who cover relevant topics
const topicKeywords = PULSE_TOPIC_KEYWORDS[topic.name] || [];
const matchedJournalists: Array<Record<string, unknown>> = [];
for (const j of journalistRes.rows) {
const journalistTopics: string[] = j.topics || [];
const relevantTopics = journalistTopics.filter((t: string) => topicKeywords.includes(t));
if (relevantTopics.length > 0) {
matchedJournalists.push({
id: j.id,
name: j.name,
outlet: j.outlet,
articleCount: parseInt(j.article_count, 10),
relevantTopics,
});
}
}
// Find orgs in the same state
const polState = pol.state as string;
const stateOrgs = (polState ? orgsByState.get(polState) : []) || [];
const limitedOrgs = stateOrgs.slice(0, 5).map((o) => ({
name: o.name,
type: o.type,
state: o.state,
}));
// Score calculation
const linkWeight = link.weight || 0;
const urgencyMultiplier = topic.urgency === 'critical' ? 1.5 : topic.urgency === 'high' ? 1.2 : 1.0;
const districtScore = pol.opportunity_score ? parseFloat(String(pol.opportunity_score)) : 0;
const journalistBonus = matchedJournalists.length * 5;
const orgBonus = limitedOrgs.length * 2;
const committees = (pol.committees as string[]) || [];
const relevantCommittees = CATEGORY_COMMITTEES[topic.category] || [];
const committeeOverlap = committees.filter((c: string) => relevantCommittees.includes(c)).length;
const committeeBonus = committeeOverlap * 8;
const rawScore =
linkWeight * 20 +
districtScore * 0.3 +
journalistBonus +
orgBonus +
committeeBonus;
const finalScore = parseFloat((rawScore * urgencyMultiplier).toFixed(1));
// Build reasoning
const reasons: string[] = [];
if (topic.urgency === 'critical') reasons.push(`${topic.name} is critical`);
else if (topic.urgency === 'high') reasons.push(`${topic.name} is high urgency`);
if (committeeOverlap > 0) reasons.push(`politician on ${committeeOverlap} relevant committee(s)`);
if (districtScore > 50) reasons.push(`high-debt district (score ${districtScore.toFixed(0)})`);
if (matchedJournalists.length > 0) reasons.push(`${matchedJournalists.length} journalist(s) covering this`);
if (limitedOrgs.length > 0) reasons.push(`${limitedOrgs.length} org(s) in same state`);
opportunities.push({
id: randomUUID(),
score: finalScore,
topic: {
id: topic.id,
name: topic.name,
urgency: topic.urgency,
category: topic.category,
},
politician: {
id: pol.id,
name: pol.name,
party: pol.party,
state: pol.state,
committees: pol.committees,
photoUrl: pol.photo_url,
},
district: pol.district_id
? {
state: pol.district_state || pol.state,
district: pol.district_number,
opportunityScore: districtScore,
}
: null,
journalists: matchedJournalists.slice(0, 5),
orgs: limitedOrgs,
reasoning: reasons.join(' + '),
});
}
// Sort by score descending, take limit
opportunities.sort((a, b) => b.score - a.score);
const limited = opportunities.slice(0, limit);
return NextResponse.json({
opportunities: limited,
meta: {
total: limited.length,
totalUnfiltered: opportunities.length,
filters: {
urgency: urgencyFilter || 'critical,high',
state: stateFilter || null,
},
},
});
} catch (err) {
console.error('[connections/opportunities] Error:', (err as Error).message);
return NextResponse.json(
{ error: 'Failed to compute opportunities' },
{ status: 500 },
);
}
}