From 13e157796825b868641294891124bed6a8c0e6b2 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sat, 28 Feb 2026 23:29:58 -0500 Subject: [PATCH] fix(members): link sponsors to bills and fix member search - Poller now fetches bill detail on insert to get sponsor (list endpoint has no sponsor data) - Add backfill_sponsor_ids task + admin endpoint + UI button to fix the 1,616 existing bills with NULL sponsor_id - Member name search now matches both "Last, First" and "First Last" using split_part() on the stored name column; same fix applied to global search - Load Bill.sponsor relationship eagerly in get_member_bills to prevent MissingGreenlet error during Pydantic serialization - Remove .trim() on search onChange so spaces can be typed Authored-By: Jack Levy --- backend/app/api/admin.py | 7 +++++ backend/app/api/members.py | 14 +++++++--- backend/app/api/search.py | 13 ++++++--- backend/app/workers/congress_poller.py | 37 ++++++++++++++++++++++++-- frontend/app/bills/page.tsx | 2 +- frontend/app/members/page.tsx | 2 +- frontend/app/settings/page.tsx | 6 +++++ frontend/lib/api.ts | 2 ++ 8 files changed, 73 insertions(+), 10 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index e20dc7c..2233aeb 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -110,6 +110,13 @@ async def get_stats( # ── Celery Tasks ────────────────────────────────────────────────────────────── +@router.post("/backfill-sponsors") +async def backfill_sponsors(current_user: User = Depends(get_current_admin)): + from app.workers.congress_poller import backfill_sponsor_ids + task = backfill_sponsor_ids.delay() + return {"task_id": task.id, "status": "queued"} + + @router.post("/trigger-poll") async def trigger_poll(current_user: User = Depends(get_current_admin)): from app.workers.congress_poller import poll_congress_bills diff --git a/backend/app/api/members.py b/backend/app/api/members.py index 084efe4..6837d7b 100644 --- a/backend/app/api/members.py +++ b/backend/app/api/members.py @@ -1,7 +1,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import desc, func, select +from sqlalchemy import desc, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -30,7 +30,15 @@ async def list_members( if state: query = query.where(Member.state == state) if q: - query = query.where(Member.name.ilike(f"%{q}%")) + # name is stored as "Last, First" — also match "First Last" order + first_last = func.concat( + func.split_part(Member.name, ", ", 2), " ", + func.split_part(Member.name, ", ", 1), + ) + query = query.where(or_( + Member.name.ilike(f"%{q}%"), + first_last.ilike(f"%{q}%"), + )) total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0 query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page) @@ -62,7 +70,7 @@ async def get_member_bills( per_page: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): - query = select(Bill).options(selectinload(Bill.briefs)).where(Bill.sponsor_id == bioguide_id) + query = select(Bill).options(selectinload(Bill.briefs), selectinload(Bill.sponsor)).where(Bill.sponsor_id == bioguide_id) total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0 query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page) diff --git a/backend/app/api/search.py b/backend/app/api/search.py index ac81008..abdb4be 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, Query -from sqlalchemy import select, text +from sqlalchemy import func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -38,10 +38,17 @@ async def search( seen.add(b.bill_id) bills.append(b) - # Fuzzy member search + # Fuzzy member search — matches "Last, First" and "First Last" + first_last = func.concat( + func.split_part(Member.name, ", ", 2), " ", + func.split_part(Member.name, ", ", 1), + ) member_results = await db.execute( select(Member) - .where(Member.name.ilike(f"%{q}%")) + .where(or_( + Member.name.ilike(f"%{q}%"), + first_last.ilike(f"%{q}%"), + )) .order_by(Member.last_name) .limit(10) ) diff --git a/backend/app/workers/congress_poller.py b/backend/app/workers/congress_poller.py index 08b9f0f..f27d40d 100644 --- a/backend/app/workers/congress_poller.py +++ b/backend/app/workers/congress_poller.py @@ -71,8 +71,14 @@ def poll_congress_bills(self): existing = db.get(Bill, bill_id) if existing is None: - # Upsert sponsor member if referenced - sponsor_id = _sync_sponsor(db, bill_data) + # Bill list endpoint has no sponsor data — fetch detail to get it + try: + detail = congress_api.get_bill_detail( + current_congress, parsed["bill_type"], parsed["bill_number"] + ) + sponsor_id = _sync_sponsor(db, detail.get("bill", {})) + except Exception: + sponsor_id = None parsed["sponsor_id"] = sponsor_id parsed["last_checked_at"] = datetime.now(timezone.utc) db.add(Bill(**parsed)) @@ -165,6 +171,33 @@ def _sync_sponsor(db, bill_data: dict) -> str | None: return bioguide_id +@celery_app.task(bind=True, name="app.workers.congress_poller.backfill_sponsor_ids") +def backfill_sponsor_ids(self): + """Backfill sponsor_id for all bills where it is NULL by fetching bill detail from Congress.gov.""" + import time + db = get_sync_db() + try: + bills = db.query(Bill).filter(Bill.sponsor_id.is_(None)).all() + total = len(bills) + updated = 0 + logger.info(f"Backfilling sponsors for {total} bills") + for bill in bills: + try: + detail = congress_api.get_bill_detail(bill.congress_number, bill.bill_type, bill.bill_number) + sponsor_id = _sync_sponsor(db, detail.get("bill", {})) + if sponsor_id: + bill.sponsor_id = sponsor_id + db.commit() + updated += 1 + except Exception as e: + logger.warning(f"Could not backfill sponsor for {bill.bill_id}: {e}") + time.sleep(0.1) # ~10 req/sec, well under Congress.gov 5000/hr limit + logger.info(f"Sponsor backfill complete: {updated}/{total} updated") + return {"total": total, "updated": updated} + finally: + db.close() + + def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool: """Update bill fields if anything has changed. Returns True if updated.""" changed = False diff --git a/frontend/app/bills/page.tsx b/frontend/app/bills/page.tsx index 001afe4..fb010a9 100644 --- a/frontend/app/bills/page.tsx +++ b/frontend/app/bills/page.tsx @@ -45,7 +45,7 @@ export default function BillsPage() { type="text" placeholder="Search bills..." value={q} - onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }} + onChange={(e) => { setQ(e.target.value); setPage(1); }} className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" /> diff --git a/frontend/app/members/page.tsx b/frontend/app/members/page.tsx index 1aebb86..dec4d19 100644 --- a/frontend/app/members/page.tsx +++ b/frontend/app/members/page.tsx @@ -32,7 +32,7 @@ export default function MembersPage() { type="text" placeholder="Search by name..." value={q} - onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }} + onChange={(e) => { setQ(e.target.value); setPage(1); }} className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none" /> diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 10ddbcd..325a424 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -346,6 +346,12 @@ export default function SettingsPage() { > Calculate Trends + {Object.entries(taskIds).map(([name, id]) => (

{name}: task {id} queued

diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2ecffe3..ac6fa27 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -151,6 +151,8 @@ export const adminAPI = { apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data), triggerTrendScores: () => apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data), + backfillSponsors: () => + apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data), getTaskStatus: (taskId: string) => apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data), };