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
Loading...
; if (!member) return
Member not found.
; + const currentLeadership = member.leadership_json?.filter((l) => l.current); + const termsSorted = [...(member.terms_json || [])].reverse(); + return (
-
-
- - - -
-

{member.name}

-
- {member.party && ( - - {member.party} - + {/* Back */} + + + Members + + + {/* Bio header */} +
+
+
+ {member.photo_url ? ( + {member.name} + ) : ( +
+ +
+ )} +
+
+

{member.name}

+
+ {member.party && ( + + {member.party} + + )} + {member.chamber && {member.chamber}} + {member.state && {member.state}} + {member.district && District {member.district}} + {member.birth_year && ( + b. {member.birth_year} + )} +
+
+ + {/* Leadership */} + {currentLeadership && currentLeadership.length > 0 && ( +
+ {currentLeadership.map((l, i) => ( + + + {l.type} + + ))} +
)} - {member.state && {member.state}} - {member.chamber && {member.chamber}} - {member.district && District {member.district}} + + {/* Contact */} +
+ {member.address && ( +
+ + {member.address} +
+ )} + {member.phone && ( + + )} + {member.official_url && ( + + )} + {member.congress_url && ( + + )} +
+ +
-
-
-

Sponsored Bills ({billsData?.total ?? 0})

- {!billsData?.items?.length ? ( -

No bills found.

- ) : ( -
- {billsData.items.map((bill) => )} +
+ {/* Left column */} +
+ {/* Sponsored bills */} +
+

+ + Sponsored Bills + {billsData?.total != null && ( + ({billsData.total}) + )} +

+ {!billsData?.items?.length ? ( +

No bills found.

+ ) : ( +
+ {billsData.items.map((bill) => )} +
+ )}
- )} +
+ + {/* Right column */} +
+ {/* Legislation stats */} + {(member.sponsored_count != null || member.cosponsored_count != null) && ( +
+

Legislation

+
+ {member.sponsored_count != null && ( +
+ Sponsored + {member.sponsored_count.toLocaleString()} +
+ )} + {member.cosponsored_count != null && ( +
+ Cosponsored + {member.cosponsored_count.toLocaleString()} +
+ )} +
+
+ )} + + {/* Service history */} + {termsSorted.length > 0 && ( +
+

Service History

+
+ {termsSorted.map((term, i) => ( +
+
+ {term.congress ? `${ordinal(term.congress)} Congress` : ""} + {term.startYear && term.endYear + ? ` (${term.startYear}–${term.endYear})` + : term.startYear + ? ` (${term.startYear}–present)` + : ""} +
+
+ {[term.chamber, term.partyName, term.stateName].filter(Boolean).join(" · ")} + {term.district ? ` · District ${term.district}` : ""} +
+
+ ))} +
+
+ )} + + {/* All leadership roles */} + {member.leadership_json && member.leadership_json.length > 0 && ( +
+

Leadership Roles

+
+ {member.leadership_json.map((l, i) => ( +
+ {l.type} + + {l.congress ? `${ordinal(l.congress)}` : ""} + +
+ ))} +
+
+ )} +
); diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 9371ee7..13416d9 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -1,3 +1,20 @@ +export interface MemberTerm { + congress?: number; + chamber?: string; + partyName?: string; + stateCode?: string; + stateName?: string; + startYear?: number; + endYear?: number; + district?: number; +} + +export interface MemberLeadership { + type?: string; + congress?: number; + current?: boolean; +} + export interface Member { bioguide_id: string; name: string; @@ -8,6 +25,15 @@ export interface Member { chamber?: string; district?: string; photo_url?: string; + official_url?: string; + congress_url?: string; + birth_year?: string; + address?: string; + phone?: string; + terms_json?: MemberTerm[]; + leadership_json?: MemberLeadership[]; + sponsored_count?: number; + cosponsored_count?: number; } export interface CitedPoint {