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