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
This commit is contained in:
Jack Levy
2026-02-28 23:29:58 -05:00
parent 795385dcba
commit 13e1577968
8 changed files with 73 additions and 10 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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"
/>
</div>

View File

@@ -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"
/>
</div>

View File

@@ -346,6 +346,12 @@ export default function SettingsPage() {
>
<RefreshCw className="w-3.5 h-3.5" /> Calculate Trends
</button>
<button
onClick={() => trigger("sponsors", adminAPI.backfillSponsors)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Backfill Sponsors
</button>
</div>
{Object.entries(taskIds).map(([name, id]) => (
<p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p>

View File

@@ -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),
};