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 typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
@@ -8,6 +10,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Bill, Member
|
from app.models import Bill, Member
|
||||||
from app.schemas.schemas import BillSchema, MemberSchema, PaginatedResponse
|
from app.schemas.schemas import BillSchema, MemberSchema, PaginatedResponse
|
||||||
|
from app.services import congress_api
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
member = await db.get(Member, bioguide_id)
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(status_code=404, detail="Member not found")
|
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
|
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.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -18,6 +18,15 @@ class Member(Base):
|
|||||||
district = Column(String(50))
|
district = Column(String(50))
|
||||||
photo_url = Column(String)
|
photo_url = Column(String)
|
||||||
official_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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=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
|
chamber: Optional[str] = None
|
||||||
district: Optional[str] = None
|
district: Optional[str] = None
|
||||||
photo_url: 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}
|
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:
|
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", [])
|
terms = data.get("terms", {}).get("item", [])
|
||||||
current_term = terms[-1] if terms else {}
|
current_term = terms[-1] if terms else {}
|
||||||
return {
|
return {
|
||||||
@@ -118,3 +118,50 @@ def parse_member_from_api(data: dict) -> dict:
|
|||||||
"photo_url": data.get("depiction", {}).get("imageUrl"),
|
"photo_url": data.get("depiction", {}).get("imageUrl"),
|
||||||
"official_url": data.get("officialWebsiteUrl"),
|
"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 { use } from "react";
|
||||||
import Link from "next/link";
|
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 { useMember, useMemberBills } from "@/lib/hooks/useMembers";
|
||||||
import { FollowButton } from "@/components/shared/FollowButton";
|
import { FollowButton } from "@/components/shared/FollowButton";
|
||||||
import { BillCard } from "@/components/shared/BillCard";
|
import { BillCard } from "@/components/shared/BillCard";
|
||||||
import { cn, partyBadgeColor } from "@/lib/utils";
|
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 }> }) {
|
export default function MemberDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const { data: member, isLoading } = useMember(id);
|
const { data: member, isLoading } = useMember(id);
|
||||||
@@ -16,39 +32,193 @@ export default function MemberDetailPage({ params }: { params: Promise<{ id: str
|
|||||||
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
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>;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between">
|
{/* Back */}
|
||||||
<div className="flex items-start gap-4">
|
<Link href="/members" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<Link href="/members" className="mt-1 text-muted-foreground hover:text-foreground">
|
<ArrowLeft className="w-4 h-4" />
|
||||||
<ArrowLeft className="w-4 h-4" />
|
Members
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">{member.name}</h1>
|
{/* Bio header */}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
{member.party && (
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
|
<div className="flex items-start gap-5">
|
||||||
{member.party}
|
{member.photo_url ? (
|
||||||
</span>
|
<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.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.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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
{member.state && <span className="text-sm text-muted-foreground">{member.state}</span>}
|
|
||||||
{member.chamber && <span className="text-sm text-muted-foreground">{member.chamber}</span>}
|
{/* Contact */}
|
||||||
{member.district && <span className="text-sm text-muted-foreground">District {member.district}</span>}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FollowButton type="member" value={member.bioguide_id} />
|
||||||
</div>
|
</div>
|
||||||
<FollowButton type="member" value={member.bioguide_id} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<h2 className="font-semibold mb-4">Sponsored Bills ({billsData?.total ?? 0})</h2>
|
{/* Left column */}
|
||||||
{!billsData?.items?.length ? (
|
<div className="md:col-span-2 space-y-6">
|
||||||
<p className="text-sm text-muted-foreground">No bills found.</p>
|
{/* Sponsored bills */}
|
||||||
) : (
|
<div>
|
||||||
<div className="space-y-3">
|
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
{billsData.items.map((bill) => <BillCard key={bill.bill_id} bill={bill} />)}
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{billsData.items.map((bill) => <BillCard key={bill.bill_id} bill={bill} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
</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 {
|
export interface Member {
|
||||||
bioguide_id: string;
|
bioguide_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,6 +25,15 @@ export interface Member {
|
|||||||
chamber?: string;
|
chamber?: string;
|
||||||
district?: string;
|
district?: string;
|
||||||
photo_url?: 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 {
|
export interface CitedPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user