diff --git a/backend/alembic/versions/0007_add_member_bio_fields.py b/backend/alembic/versions/0007_add_member_bio_fields.py new file mode 100644 index 0000000..676b671 --- /dev/null +++ b/backend/alembic/versions/0007_add_member_bio_fields.py @@ -0,0 +1,37 @@ +"""add member bio and contact fields + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-03-01 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("members", sa.Column("congress_url", sa.String(), nullable=True)) + op.add_column("members", sa.Column("birth_year", sa.String(10), nullable=True)) + op.add_column("members", sa.Column("address", sa.String(), nullable=True)) + op.add_column("members", sa.Column("phone", sa.String(50), nullable=True)) + op.add_column("members", sa.Column("terms_json", sa.JSON(), nullable=True)) + op.add_column("members", sa.Column("leadership_json", sa.JSON(), nullable=True)) + op.add_column("members", sa.Column("sponsored_count", sa.Integer(), nullable=True)) + op.add_column("members", sa.Column("cosponsored_count", sa.Integer(), nullable=True)) + op.add_column("members", sa.Column("detail_fetched", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade(): + op.drop_column("members", "congress_url") + op.drop_column("members", "birth_year") + op.drop_column("members", "address") + op.drop_column("members", "phone") + op.drop_column("members", "terms_json") + op.drop_column("members", "leadership_json") + op.drop_column("members", "sponsored_count") + op.drop_column("members", "cosponsored_count") + op.drop_column("members", "detail_fetched") diff --git a/backend/app/api/members.py b/backend/app/api/members.py index 6837d7b..13f4519 100644 --- a/backend/app/api/members.py +++ b/backend/app/api/members.py @@ -1,3 +1,5 @@ +import logging +from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -8,6 +10,9 @@ from sqlalchemy.orm import selectinload from app.database import get_db from app.models import Bill, Member from app.schemas.schemas import BillSchema, MemberSchema, PaginatedResponse +from app.services import congress_api + +logger = logging.getLogger(__name__) router = APIRouter() @@ -60,6 +65,21 @@ async def get_member(bioguide_id: str, db: AsyncSession = Depends(get_db)): member = await db.get(Member, bioguide_id) if not member: raise HTTPException(status_code=404, detail="Member not found") + + # Lazy-enrich with detail data from Congress.gov on first view + if member.detail_fetched is None: + try: + detail_raw = congress_api.get_member_detail(bioguide_id) + enriched = congress_api.parse_member_detail_from_api(detail_raw) + for field, value in enriched.items(): + if value is not None: + setattr(member, field, value) + member.detail_fetched = datetime.now(timezone.utc) + await db.commit() + await db.refresh(member) + except Exception as e: + logger.warning(f"Could not enrich member detail for {bioguide_id}: {e}") + return member diff --git a/backend/app/models/member.py b/backend/app/models/member.py index 96c7b52..177fc2d 100644 --- a/backend/app/models/member.py +++ b/backend/app/models/member.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime +from sqlalchemy import Column, Integer, JSON, String, DateTime from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -18,6 +18,15 @@ class Member(Base): district = Column(String(50)) photo_url = Column(String) official_url = Column(String) + congress_url = Column(String) + birth_year = Column(String(10)) + address = Column(String) + phone = Column(String(50)) + terms_json = Column(JSON) + leadership_json = Column(JSON) + sponsored_count = Column(Integer) + cosponsored_count = Column(Integer) + detail_fetched = Column(DateTime(timezone=True)) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 4af1a23..1ca26c4 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -26,6 +26,15 @@ class MemberSchema(BaseModel): chamber: Optional[str] = None district: Optional[str] = None photo_url: Optional[str] = None + official_url: Optional[str] = None + congress_url: Optional[str] = None + birth_year: Optional[str] = None + address: Optional[str] = None + phone: Optional[str] = None + terms_json: Optional[list[Any]] = None + leadership_json: Optional[list[Any]] = None + sponsored_count: Optional[int] = None + cosponsored_count: Optional[int] = None model_config = {"from_attributes": True} diff --git a/backend/app/services/congress_api.py b/backend/app/services/congress_api.py index 70eabc9..cbd860f 100644 --- a/backend/app/services/congress_api.py +++ b/backend/app/services/congress_api.py @@ -103,7 +103,7 @@ def parse_bill_from_api(data: dict, congress: int) -> dict: def parse_member_from_api(data: dict) -> dict: - """Normalize raw API member data into our model fields.""" + """Normalize raw API member list data into our model fields.""" terms = data.get("terms", {}).get("item", []) current_term = terms[-1] if terms else {} return { @@ -118,3 +118,50 @@ def parse_member_from_api(data: dict) -> dict: "photo_url": data.get("depiction", {}).get("imageUrl"), "official_url": data.get("officialWebsiteUrl"), } + + +def parse_member_detail_from_api(data: dict) -> dict: + """Normalize Congress.gov member detail response into enrichment fields.""" + member = data.get("member", data) + addr = member.get("addressInformation") or {} + terms_raw = member.get("terms", []) + if isinstance(terms_raw, dict): + terms_raw = terms_raw.get("item", []) + leadership_raw = member.get("leadership") or [] + if isinstance(leadership_raw, dict): + leadership_raw = leadership_raw.get("item", []) + first = member.get("firstName", "") + last = member.get("lastName", "") + bioguide_id = member.get("bioguideId", "") + slug = f"{first}-{last}".lower().replace(" ", "-").replace("'", "") + return { + "birth_year": str(member["birthYear"]) if member.get("birthYear") else None, + "address": addr.get("officeAddress"), + "phone": addr.get("phoneNumber"), + "official_url": member.get("officialWebsiteUrl"), + "photo_url": (member.get("depiction") or {}).get("imageUrl"), + "congress_url": f"https://www.congress.gov/member/{slug}/{bioguide_id}" if bioguide_id else None, + "terms_json": [ + { + "congress": t.get("congress"), + "chamber": t.get("chamber"), + "partyName": t.get("partyName"), + "stateCode": t.get("stateCode"), + "stateName": t.get("stateName"), + "startYear": t.get("startYear"), + "endYear": t.get("endYear"), + "district": t.get("district"), + } + for t in terms_raw + ], + "leadership_json": [ + { + "type": l.get("type"), + "congress": l.get("congress"), + "current": l.get("current"), + } + for l in leadership_raw + ], + "sponsored_count": (member.get("sponsoredLegislation") or {}).get("count"), + "cosponsored_count": (member.get("cosponsoredLegislation") or {}).get("count"), + } diff --git a/frontend/app/members/[id]/page.tsx b/frontend/app/members/[id]/page.tsx index 32b2cd2..a755b6f 100644 --- a/frontend/app/members/[id]/page.tsx +++ b/frontend/app/members/[id]/page.tsx @@ -2,12 +2,28 @@ import { use } from "react"; import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; +import Image from "next/image"; +import { + ArrowLeft, + ExternalLink, + MapPin, + Phone, + Globe, + Star, + FileText, + Users, +} from "lucide-react"; import { useMember, useMemberBills } from "@/lib/hooks/useMembers"; import { FollowButton } from "@/components/shared/FollowButton"; import { BillCard } from "@/components/shared/BillCard"; import { cn, partyBadgeColor } from "@/lib/utils"; +function ordinal(n: number) { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +} + export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { data: member, isLoading } = useMember(id); @@ -16,39 +32,193 @@ export default function MemberDetailPage({ params }: { params: Promise<{ id: str if (isLoading) return
No bills found.
- ) : ( -No bills found.
+ ) : ( +