← back to Norma
app/api/orgs/match/route.ts
273 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
/**
* Tag normalization: bridges vocabulary gaps between news tags (hyphenated)
* and org key_issues (underscored), plus maps related terms.
*/
const TAG_ALIASES: Record<string, string[]> = {
'student_debt': ['student-debt', 'student-loan', 'student-loans', 'student-loan-forgiveness', 'pslf-public-service-loan-forgiveness', 'SAVE-plan', 'borrower-experience', 'repayment', 'default', 'collections', 'forgiveness'],
'education': ['education', 'higher-education', 'college', 'university', 'tuition'],
'healthcare': ['healthcare', 'health', 'public-health', 'mental-health', 'medicaid', 'medicare'],
'economic_justice': ['economic-justice', 'poverty', 'inequality', 'wages', 'housing', 'cost-of-living'],
'civil_rights': ['civil-rights', 'voting-rights', 'discrimination', 'equality', 'dei'],
'climate': ['climate', 'environment', 'clean-energy', 'emissions', 'sustainability'],
'immigration': ['immigration', 'daca', 'border', 'refugee', 'asylum'],
'criminal_justice': ['criminal-justice', 'police-reform', 'incarceration', 'sentencing'],
'voting_rights': ['voting-rights', 'election', 'voter-registration', 'gerrymandering'],
'labor': ['labor', 'unions', 'workers-rights', 'minimum-wage', 'gig-economy'],
'gun_control': ['gun-control', 'gun-violence', 'firearms', 'mass-shooting'],
'foreign_policy': ['foreign-policy', 'iran', 'diplomacy', 'sanctions', 'military'],
'child_safety': ['child-safety', 'children', 'youth', 'child-welfare'],
'technology': ['technology', 'ai', 'privacy', 'data-protection', 'social-media'],
'policy': ['policy', 'legislation', 'regulation', 'legal', 'congress'],
};
/** Normalize a tag to its canonical issue key */
function normalizeTag(tag: string): string[] {
const lower = tag.toLowerCase().trim();
const matches: string[] = [];
for (const [canonical, aliases] of Object.entries(TAG_ALIASES)) {
if (canonical === lower || lower.replace(/-/g, '_') === canonical) {
matches.push(canonical);
} else if (aliases.some(a => a === lower || lower.includes(a) || a.includes(lower))) {
matches.push(canonical);
}
}
return matches.length > 0 ? matches : [lower.replace(/-/g, '_')];
}
/** Political alignment score: how compatible is the org with the topic sentiment */
function politicalScore(orgLeaning: string | null, topicSentiment: number | null): number {
if (!orgLeaning) return 0.5; // neutral/unknown = partial match
// sentiment: -1 (progressive) to +1 (conservative), 0 = neutral
const sent = topicSentiment ?? 0;
const leanMap: Record<string, number> = {
progressive: -0.8, center_left: -0.4, moderate: 0, nonpartisan: 0,
center_right: 0.4, conservative: 0.8,
};
const orgPos = leanMap[orgLeaning] ?? 0;
// Closer positions = higher score. Max distance is 1.6
const distance = Math.abs(orgPos - sent);
return Math.max(0, 1 - distance / 1.6);
}
/** Urgency compatibility: org threshold vs topic urgency */
function urgencyScore(orgThreshold: string | null, topicUrgency: string | null): number {
if (!orgThreshold || !topicUrgency) return 0.5;
const levels: Record<string, number> = { low: 1, moderate: 2, high: 3, critical: 4 };
const orgLevel = levels[orgThreshold] ?? 2;
const topicLevel = levels[topicUrgency] ?? 2;
// Orgs with lower thresholds are willing to act on less urgent topics
if (topicLevel >= orgLevel) return 1.0; // topic meets or exceeds threshold
return 0.3; // topic below org's threshold
}
/** Capacity signal from supporter count and outreach channels */
function capacityScore(supporterCount: string | null, channels: string[] | null): number {
const sizeMap: Record<string, number> = {
'under_100': 0.2, '100-500': 0.4, '500-1000': 0.5,
'1000-5000': 0.7, '5000-10000': 0.85, '10000_plus': 1.0,
};
const sizeScore = sizeMap[supporterCount ?? ''] ?? 0.3;
const channelCount = (channels || []).length;
const channelScore = Math.min(1, channelCount / 5);
return sizeScore * 0.6 + channelScore * 0.4;
}
/**
* GET /api/orgs/match
* Match organizations to a news topic or set of tags.
*
* Query params:
* news_id - UUID of a news_items row (gets tags from DB)
* tags - comma-separated tags (alternative to news_id)
* urgency - topic urgency: low, moderate, high, critical
* sentiment - topic political sentiment: -1.0 to +1.0
* geo_state - topic geographic state focus
* action_type - petition, rally, email_campaign, testimony, etc.
* limit - max results (default 50)
* min_score - minimum match score 0-100 (default 10)
*/
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 newsId = searchParams.get('news_id');
const tagsParam = searchParams.get('tags');
const urgency = searchParams.get('urgency');
const sentiment = searchParams.get('sentiment') ? parseFloat(searchParams.get('sentiment')!) : null;
const geoState = searchParams.get('geo_state');
const actionType = searchParams.get('action_type');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const minScore = parseInt(searchParams.get('min_score') || '10');
// Resolve topic tags
let topicTags: string[] = [];
let newsHeadline: string | null = null;
if (newsId) {
const newsResult = await query(
'SELECT headline, tags, raw_data FROM news_items WHERE id = $1',
[newsId],
);
if (newsResult.rowCount === 0) {
return NextResponse.json({ error: 'News item not found' }, { status: 404 });
}
topicTags = newsResult.rows[0].tags || [];
newsHeadline = newsResult.rows[0].headline;
} else if (tagsParam) {
topicTags = tagsParam.split(',').map(t => t.trim()).filter(Boolean);
} else {
return NextResponse.json({ error: 'Provide news_id or tags parameter' }, { status: 400 });
}
// Normalize topic tags to canonical issue keys
const canonicalIssues = new Set<string>();
for (const tag of topicTags) {
for (const norm of normalizeTag(tag)) {
canonicalIssues.add(norm);
}
}
const issueArray = Array.from(canonicalIssues);
// Fetch all orgs with at least some data
const orgsResult = await query(
`SELECT id, name, key_issues, focus_populations, political_leaning,
state, city, metro_area, type, category, email, phone, website,
advocacy_intensity, bipartisan_willingness, supporter_count,
outreach_channels, response_speed, urgency_threshold,
sentiment_preference, preferred_actions, geo_focus,
onboard_status, description, mission
FROM organizations
WHERE key_issues IS NOT NULL AND array_length(key_issues, 1) > 0
ORDER BY name`,
);
// Score each org
const scored: Array<{
org: Record<string, unknown>;
score: number;
factors: Record<string, number>;
reasons: string[];
}> = [];
for (const org of orgsResult.rows) {
const orgIssues = (org.key_issues || []).map((i: string) => i.toLowerCase().replace(/-/g, '_'));
const reasons: string[] = [];
// 1. Issue overlap (35%)
const issueOverlap = issueArray.filter(ci =>
orgIssues.some((oi: string) => oi === ci || oi.includes(ci) || ci.includes(oi)),
).length;
const issueFactor = issueOverlap / Math.max(issueArray.length, 1);
if (issueOverlap > 0) reasons.push(`${issueOverlap} issue${issueOverlap > 1 ? 's' : ''} in common`);
// 2. Geographic alignment (20%)
let geoFactor = 0.3; // base: unknown geo = partial
if (geoState && org.state) {
if (org.state.toLowerCase() === geoState.toLowerCase()) {
geoFactor = 1.0;
reasons.push('Same state');
} else {
geoFactor = 0.15;
}
} else if (org.geo_focus === 'national') {
geoFactor = 0.7;
reasons.push('National focus');
}
// 3. Political fit (15%)
const polFactor = politicalScore(org.political_leaning, sentiment);
if (polFactor > 0.7) reasons.push('Political alignment');
// 4. Urgency fit (10%)
const urgFactor = urgencyScore(org.urgency_threshold, urgency);
if (urgFactor > 0.7) reasons.push('Urgency match');
// 5. Action alignment (10%)
let actionFactor = 0.5;
if (actionType && org.preferred_actions && org.preferred_actions.length > 0) {
const acts = (org.preferred_actions as string[]).map(a => a.toLowerCase());
if (acts.includes(actionType.toLowerCase())) {
actionFactor = 1.0;
reasons.push('Preferred action type');
} else {
actionFactor = 0.2;
}
}
// 6. Capacity signal (10%)
const capFactor = capacityScore(org.supporter_count, org.outreach_channels);
if (capFactor > 0.7) reasons.push('Strong outreach capacity');
// Weighted score
const raw = (
issueFactor * 35 +
geoFactor * 20 +
polFactor * 15 +
urgFactor * 10 +
actionFactor * 10 +
capFactor * 10
);
const score = Math.round(Math.min(100, raw));
if (score >= minScore) {
scored.push({
org: {
id: org.id,
name: org.name,
type: org.type,
category: org.category,
political_leaning: org.political_leaning,
state: org.state,
city: org.city,
key_issues: org.key_issues,
email: org.email,
phone: org.phone,
website: org.website,
mission: org.mission,
description: org.description,
onboard_status: org.onboard_status,
supporter_count: org.supporter_count,
advocacy_intensity: org.advocacy_intensity,
},
score,
factors: {
issue_overlap: Math.round(issueFactor * 100),
geographic: Math.round(geoFactor * 100),
political_fit: Math.round(polFactor * 100),
urgency_fit: Math.round(urgFactor * 100),
action_alignment: Math.round(actionFactor * 100),
capacity: Math.round(capFactor * 100),
},
reasons,
});
}
}
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
return NextResponse.json({
topic: {
news_id: newsId || null,
headline: newsHeadline,
tags: topicTags,
canonical_issues: issueArray,
urgency: urgency || null,
sentiment: sentiment ?? null,
geo_state: geoState || null,
},
matches: scored.slice(0, limit),
total: scored.length,
});
} catch (err) {
console.error('[api/orgs/match] Error:', (err as Error).message);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}