← back to Norma
app/api/geo/advocacy/route.ts
118 lines
/**
* GET /api/geo/advocacy
*
* Returns zip_profiles ordered by advocacy_readiness_score DESC.
* For identifying top campaign-targeting ZIP codes.
*
* Optional filters:
* ?state=CA — filter by state abbreviation
* ?min_score=50 — minimum advocacy_readiness_score
*
* Returns top 100 with all zip_profile fields.
* Requires authentication.
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { searchParams } = new URL(request.url);
const state = searchParams.get('state');
const minScoreParam = searchParams.get('min_score');
// Validate min_score if provided
let minScore: number | null = null;
if (minScoreParam) {
minScore = Number(minScoreParam);
if (isNaN(minScore) || minScore < 0 || minScore > 100) {
return NextResponse.json(
{ error: 'min_score must be a number between 0 and 100' },
{ status: 400 },
);
}
}
try {
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
// Always filter for non-null advocacy scores
conditions.push('advocacy_readiness_score IS NOT NULL');
if (state) {
conditions.push(`state = $${paramIndex++}`);
values.push(state.toUpperCase());
}
if (minScore !== null) {
conditions.push(`advocacy_readiness_score >= $${paramIndex++}`);
values.push(minScore);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const { rows } = await query(
`SELECT
zip, state, county_fips, county_name, pop_25_plus,
complaint_count, complaint_density, bachelors_pct, grad_pct,
school_count, avg_median_debt, avg_pell_rate, max_debt_school,
community_mentions, avg_sentiment_score, frustrated_pct,
debt_distress_score, advocacy_readiness_score,
top_servicers, top_issues, hend_resources_count,
last_computed_at, updated_at
FROM zip_profiles
${whereClause}
ORDER BY advocacy_readiness_score DESC
LIMIT 100`,
values,
);
// Compute aggregate stats for the result set
let totalScore = 0;
let maxScore = 0;
let minScoreVal = 100;
const stateCounts: Record<string, number> = {};
for (const row of rows) {
const score = Number((row as { advocacy_readiness_score: string }).advocacy_readiness_score ?? 0);
totalScore += score;
if (score > maxScore) maxScore = score;
if (score < minScoreVal) minScoreVal = score;
const st = (row as { state: string | null }).state;
if (st) {
stateCounts[st] = (stateCounts[st] || 0) + 1;
}
}
return NextResponse.json({
filters: { state, min_score: minScore },
summary: {
total: rows.length,
avg_score: rows.length > 0 ? Math.round((totalScore / rows.length) * 100) / 100 : 0,
max_score: Math.round(maxScore * 100) / 100,
min_score: rows.length > 0 ? Math.round(minScoreVal * 100) / 100 : 0,
states_represented: Object.keys(stateCounts).length,
top_states: Object.entries(stateCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([st, count]) => ({ state: st, count })),
},
profiles: rows,
});
} catch (err) {
console.error('[geo/advocacy] Error:', (err as Error).message);
return NextResponse.json(
{ error: `Failed to fetch advocacy data: ${(err as Error).message}` },
{ status: 500 },
);
}
}