← back to Norma
app/api/petitions/[id]/geo/route.ts
161 lines
/**
* Petition Geo Targeting API
* GET /api/petitions/[id]/geo — geo breakdown (states, districts, top legislators)
* POST /api/petitions/[id]/geo — auto-target legislators based on signature distribution
*
* Inspired by: UK Parliament's "Show on a map" feature
*/
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { updatePetitionGeoStats, getTargetLegislators } from '@/lib/petition-engine';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = requireRole(req, 'admin', 'staff', 'pulse');
if (auth instanceof NextResponse) return auth;
const { id } = await params;
const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
try {
// Verify petition belongs to this org
const { rowCount: petitionExists } = await query(
`SELECT 1 FROM petitions WHERE id = $1 AND org_id = $2`,
[id, orgId],
);
if (!petitionExists) {
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
// State-level breakdown
const { rows: stateBreakdown } = await query(
`SELECT state, SUM(signature_count) AS signatures,
COUNT(DISTINCT district_code) AS districts
FROM petition_geo_stats
WHERE petition_id = $1
GROUP BY state
ORDER BY signatures DESC`,
[id],
);
// District-level breakdown with legislator info
const { rows: districtBreakdown } = await query(
`SELECT gs.state, gs.district_code, gs.signature_count, gs.constituent_pct,
gs.legislator_name, gs.legislator_party, gs.last_updated,
p.email AS legislator_email, p.title AS legislator_title,
p.committees, p.position_student_debt
FROM petition_geo_stats gs
LEFT JOIN politicians p ON p.congressional_district = gs.district_code
WHERE gs.petition_id = $1 AND gs.district_code IS NOT NULL
ORDER BY gs.signature_count DESC
LIMIT 50`,
[id],
);
// Petition totals
const { rows: petitionRows } = await query(
`SELECT title, total_signatures, target_states, target_districts
FROM petitions WHERE id = $1 AND org_id = $2`,
[id, orgId],
);
// Top 10 legislators to target
const topLegislators = await getTargetLegislators(id, 10);
return NextResponse.json({
petition: petitionRows[0] || null,
stateBreakdown: stateBreakdown.map((r) => ({
state: r.state,
signatures: Number(r.signatures),
districts: Number(r.districts),
})),
districtBreakdown: districtBreakdown.map((r) => ({
state: r.state,
districtCode: r.district_code,
signatureCount: Number(r.signature_count),
constituentPct: r.constituent_pct ? Number(r.constituent_pct) : null,
legislatorName: r.legislator_name,
legislatorParty: r.legislator_party,
legislatorEmail: r.legislator_email,
legislatorTitle: r.legislator_title,
committees: r.committees,
positionStudentDebt: r.position_student_debt,
lastUpdated: r.last_updated,
})),
topLegislators,
totalStates: stateBreakdown.length,
totalDistricts: districtBreakdown.length,
});
} catch (err) {
console.error('[geo] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch geo data' }, { status: 500 });
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = requireRole(req, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const { id } = await params;
const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
try {
// Verify petition belongs to this org
const { rowCount: petitionExists } = await query(
`SELECT 1 FROM petitions WHERE id = $1 AND org_id = $2`,
[id, orgId],
);
if (!petitionExists) {
return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
}
const body = await req.json();
const { action } = body;
if (action === 'refresh') {
// Recalculate geo stats from signatures
const breakdown = await updatePetitionGeoStats(id);
return NextResponse.json({
message: 'Geo stats refreshed',
breakdown,
totalStates: new Set(breakdown.map((b) => b.state)).size,
totalDistricts: breakdown.filter((b) => b.districtCode).length,
});
}
if (action === 'auto-target') {
// Auto-set target states/districts based on signature distribution
const topLegislators = await getTargetLegislators(id, 20);
const targetStates = [...new Set(topLegislators.map((l) => l.state))];
const targetDistricts = topLegislators
.filter((l) => l.districtCode)
.map((l) => l.districtCode);
await query(
`UPDATE petitions SET target_states = $2, target_districts = $3 WHERE id = $1 AND org_id = $4`,
[id, targetStates, targetDistricts, orgId],
);
return NextResponse.json({
message: 'Auto-targeting set',
targetStates,
targetDistricts,
topLegislators,
});
}
return NextResponse.json({ error: 'Unknown action. Use "refresh" or "auto-target".' }, { status: 400 });
} catch (err) {
console.error('[geo] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to process geo action' }, { status: 500 });
}
}