feat(members): add full member bio, contact info, and service history
Lazy-enriches member profiles on first view via Congress.gov detail API. Adds office address, phone, official website, congress.gov link, birth year, terms history, leadership roles, and sponsored/cosponsored counts. Includes DB migration 0007 for new member columns. Co-Authored-By: Jack Levy
This commit is contained in:
37
backend/alembic/versions/0007_add_member_bio_fields.py
Normal file
37
backend/alembic/versions/0007_add_member_bio_fields.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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,32 +32,118 @@ export default function MemberDetailPage({ params }: { params: Promise<{ id: str
|
||||
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
||||
if (!member) return <div className="text-center py-20 text-muted-foreground">Member not found.</div>;
|
||||
|
||||
const currentLeadership = member.leadership_json?.filter((l) => l.current);
|
||||
const termsSorted = [...(member.terms_json || [])].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/members" className="mt-1 text-muted-foreground hover:text-foreground">
|
||||
{/* Back */}
|
||||
<Link href="/members" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Members
|
||||
</Link>
|
||||
|
||||
{/* Bio header */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-5">
|
||||
{member.photo_url ? (
|
||||
<Image
|
||||
src={member.photo_url}
|
||||
alt={member.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="rounded-lg object-cover shrink-0 border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-lg bg-muted flex items-center justify-center shrink-0 border border-border">
|
||||
<Users className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{member.name}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{member.party && (
|
||||
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
|
||||
{member.party}
|
||||
</span>
|
||||
)}
|
||||
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
|
||||
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
|
||||
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
|
||||
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
|
||||
{member.birth_year && (
|
||||
<span className="text-sm text-muted-foreground">b. {member.birth_year}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton type="member" value={member.bioguide_id} />
|
||||
</div>
|
||||
|
||||
{/* Leadership */}
|
||||
{currentLeadership && currentLeadership.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{currentLeadership.map((l, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 bg-primary/10 text-primary rounded-full">
|
||||
<Star className="w-3 h-3" />
|
||||
{l.type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
||||
{member.address && (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>{member.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.phone && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={`tel:${member.phone}`} className="hover:text-foreground transition-colors">
|
||||
{member.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{member.official_url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors truncate max-w-xs">
|
||||
{member.official_url.replace(/^https?:\/\//, "")}
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{member.congress_url && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5 shrink-0" />
|
||||
<a href={member.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">
|
||||
congress.gov profile
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FollowButton type="member" value={member.bioguide_id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Sponsored bills */}
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">Sponsored Bills ({billsData?.total ?? 0})</h2>
|
||||
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Sponsored Bills
|
||||
{billsData?.total != null && (
|
||||
<span className="text-xs text-muted-foreground font-normal">({billsData.total})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!billsData?.items?.length ? (
|
||||
<p className="text-sm text-muted-foreground">No bills found.</p>
|
||||
) : (
|
||||
@@ -51,5 +153,73 @@ export default function MemberDetailPage({ params }: { params: Promise<{ id: str
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-4">
|
||||
{/* Legislation stats */}
|
||||
{(member.sponsored_count != null || member.cosponsored_count != null) && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Legislation</h3>
|
||||
<div className="space-y-2">
|
||||
{member.sponsored_count != null && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Sponsored</span>
|
||||
<span className="font-medium">{member.sponsored_count.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.cosponsored_count != null && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Cosponsored</span>
|
||||
<span className="font-medium">{member.cosponsored_count.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service history */}
|
||||
{termsSorted.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Service History</h3>
|
||||
<div className="space-y-2">
|
||||
{termsSorted.map((term, i) => (
|
||||
<div key={i} className="text-sm border-l-2 border-border pl-3">
|
||||
<div className="font-medium">
|
||||
{term.congress ? `${ordinal(term.congress)} Congress` : ""}
|
||||
{term.startYear && term.endYear
|
||||
? ` (${term.startYear}–${term.endYear})`
|
||||
: term.startYear
|
||||
? ` (${term.startYear}–present)`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[term.chamber, term.partyName, term.stateName].filter(Boolean).join(" · ")}
|
||||
{term.district ? ` · District ${term.district}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All leadership roles */}
|
||||
{member.leadership_json && member.leadership_json.length > 0 && (
|
||||
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold">Leadership Roles</h3>
|
||||
<div className="space-y-2">
|
||||
{member.leadership_json.map((l, i) => (
|
||||
<div key={i} className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className={l.current ? "font-medium" : "text-muted-foreground"}>{l.type}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{l.congress ? `${ordinal(l.congress)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user