← back to Norma
app/api/geo/hotspots/route.ts
127 lines
/**
* GET /api/geo/hotspots
*
* Returns geo_hotspots LEFT JOIN zip_profiles, with optional filters:
* ?state=CA — filter by state abbreviation
* ?type=complaint_cluster — filter by hotspot_type
* ?severity=critical — filter by severity level
*
* Ordered by z_score DESC. Limited to 100 rows.
* 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 type = searchParams.get('type');
const severity = searchParams.get('severity');
// Validate hotspot_type if provided
const validTypes = ['complaint_cluster', 'debt_desert', 'advocacy_opportunity', 'underserved'];
if (type && !validTypes.includes(type)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${validTypes.join(', ')}` },
{ status: 400 },
);
}
// Validate severity if provided
const validSeverities = ['critical', 'high', 'medium', 'low'];
if (severity && !validSeverities.includes(severity)) {
return NextResponse.json(
{ error: `Invalid severity. Must be one of: ${validSeverities.join(', ')}` },
{ status: 400 },
);
}
try {
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (state) {
conditions.push(`zp.state = $${paramIndex++}`);
values.push(state.toUpperCase());
}
if (type) {
conditions.push(`gh.hotspot_type = $${paramIndex++}`);
values.push(type);
}
if (severity) {
conditions.push(`gh.severity = $${paramIndex++}`);
values.push(severity);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
const { rows } = await query(
`SELECT
gh.id,
gh.zip,
gh.hotspot_type,
gh.z_score,
gh.rank,
gh.severity,
gh.contributing_factors,
gh.detected_at,
zp.state,
zp.county_name,
zp.complaint_count,
zp.complaint_density,
zp.bachelors_pct,
zp.grad_pct,
zp.avg_median_debt,
zp.avg_pell_rate,
zp.school_count,
zp.community_mentions,
zp.frustrated_pct,
zp.debt_distress_score,
zp.advocacy_readiness_score,
zp.pop_25_plus
FROM geo_hotspots gh
LEFT JOIN zip_profiles zp ON gh.zip = zp.zip
${whereClause}
ORDER BY gh.z_score DESC
LIMIT 100`,
values,
);
// Summary stats
const summary = {
total: rows.length,
by_type: {} as Record<string, number>,
by_severity: {} as Record<string, number>,
};
for (const row of rows) {
const t = (row as { hotspot_type: string }).hotspot_type;
const s = (row as { severity: string }).severity;
summary.by_type[t] = (summary.by_type[t] || 0) + 1;
summary.by_severity[s] = (summary.by_severity[s] || 0) + 1;
}
return NextResponse.json({
filters: { state, type, severity },
summary,
hotspots: rows,
});
} catch (err) {
console.error('[geo/hotspots] Error:', (err as Error).message);
return NextResponse.json(
{ error: `Failed to fetch hotspots: ${(err as Error).message}` },
{ status: 500 },
);
}
}