diff --git a/ROADMAP.md b/ROADMAP.md index 102a257..e9233e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,6 +56,7 @@ - [x] Weekly digest — Monday 8:30 AM UTC; 7-day summary of followed bill activity via ntfy + RSS - [x] Change-driven alerts — `categorize_action()` maps action text to 6 named categories; all three follow types (bill, sponsor, topic) covered - [x] Granular per-mode alert filters — 8 independently toggleable alert types per follow mode (Follow / Pocket Veto / Pocket Boost); preset defaults; tabbed UI with per-tab save +- [x] Roll-call votes — `bill_votes` + `member_vote_positions` tables; on-demand fetch from Congress.gov `/votes` endpoint; VotePanel on bill detail shows yea/nay bar + followed member positions ### UX & Polish - [x] Party badges — solid red/blue/slate, readable in light and dark mode @@ -71,7 +72,7 @@ ### Phase 4 — Accountability -- [ ] **Votes & Committees** — fetch roll-call votes from Congress.gov into a `bill_votes` table. UI: vote results filterable by followed members and topics; timeline entries for committee actions. +- [ ] **Vote History & Timeline** — surface roll-call votes in the action timeline; add a "Votes" filter to the bills list so users can find bills that have had floor votes. - [ ] **Member Effectiveness Score** — nightly Celery task; transparent formula: sponsored bills, bills advanced through stages, co-sponsorships, committee participation, bills enacted. Stored in `member_scores`. Displayed on member profile with formula explanation. - [ ] **Representation Alignment View** — for each followed member, show how their votes and actions align with the user's followed topics. Neutral presentation — no scorecard framing. @@ -107,6 +108,6 @@ v1.0 ships when the following are all live: - [x] Public browsing (no account required to read) - [x] Multi-user auth with admin panel - [ ] Member effectiveness score -- [ ] Roll-call vote data +- [x] Roll-call vote data All other items above are post-v1.0. diff --git a/backend/alembic/versions/0017_add_bill_votes.py b/backend/alembic/versions/0017_add_bill_votes.py new file mode 100644 index 0000000..db8beca --- /dev/null +++ b/backend/alembic/versions/0017_add_bill_votes.py @@ -0,0 +1,63 @@ +"""Add bill_votes and member_vote_positions tables + +Revision ID: 0017 +Revises: 0016 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0017" +down_revision = "0016" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "bill_votes", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("bill_id", sa.String, sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False), + sa.Column("congress", sa.Integer, nullable=False), + sa.Column("chamber", sa.String(50), nullable=False), + sa.Column("session", sa.Integer, nullable=False), + sa.Column("roll_number", sa.Integer, nullable=False), + sa.Column("question", sa.Text), + sa.Column("description", sa.Text), + sa.Column("vote_date", sa.Date), + sa.Column("yeas", sa.Integer), + sa.Column("nays", sa.Integer), + sa.Column("not_voting", sa.Integer), + sa.Column("result", sa.String(200)), + sa.Column("source_url", sa.String), + sa.Column("fetched_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + ) + op.create_index("ix_bill_votes_bill_id", "bill_votes", ["bill_id"]) + op.create_unique_constraint( + "uq_bill_votes_roll", + "bill_votes", + ["congress", "chamber", "session", "roll_number"], + ) + + op.create_table( + "member_vote_positions", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("vote_id", sa.Integer, sa.ForeignKey("bill_votes.id", ondelete="CASCADE"), nullable=False), + sa.Column("bioguide_id", sa.String, sa.ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True), + sa.Column("member_name", sa.String(200)), + sa.Column("party", sa.String(50)), + sa.Column("state", sa.String(10)), + sa.Column("position", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + ) + op.create_index("ix_member_vote_positions_vote_id", "member_vote_positions", ["vote_id"]) + op.create_index("ix_member_vote_positions_bioguide_id", "member_vote_positions", ["bioguide_id"]) + + +def downgrade(): + op.drop_index("ix_member_vote_positions_bioguide_id", "member_vote_positions") + op.drop_index("ix_member_vote_positions_vote_id", "member_vote_positions") + op.drop_table("member_vote_positions") + op.drop_index("ix_bill_votes_bill_id", "bill_votes") + op.drop_constraint("uq_bill_votes_roll", "bill_votes") + op.drop_table("bill_votes") diff --git a/backend/app/api/bills.py b/backend/app/api/bills.py index 756a97f..3ccd347 100644 --- a/backend/app/api/bills.py +++ b/backend/app/api/bills.py @@ -12,6 +12,7 @@ from app.schemas.schemas import ( BillDetailSchema, BillSchema, BillActionSchema, + BillVoteSchema, NewsArticleSchema, PaginatedResponse, TrendScoreSchema, @@ -202,6 +203,32 @@ async def get_bill_trend(bill_id: str, days: int = Query(30, ge=7, le=365), db: return result.scalars().all() +@router.get("/{bill_id}/votes", response_model=list[BillVoteSchema]) +async def get_bill_votes_endpoint(bill_id: str, db: AsyncSession = Depends(get_db)): + from app.models.vote import BillVote + from sqlalchemy.orm import selectinload + + result = await db.execute( + select(BillVote) + .where(BillVote.bill_id == bill_id) + .options(selectinload(BillVote.positions)) + .order_by(desc(BillVote.vote_date)) + ) + votes = result.scalars().unique().all() + + # Trigger background fetch if no votes are stored yet + if not votes: + bill = await db.get(Bill, bill_id) + if bill: + try: + from app.workers.vote_fetcher import fetch_bill_votes + fetch_bill_votes.delay(bill_id) + except Exception: + pass + + return votes + + @router.post("/{bill_id}/draft-letter", response_model=DraftLetterResponse) async def generate_letter(bill_id: str, body: DraftLetterRequest, db: AsyncSession = Depends(get_db)): from app.models.setting import AppSetting diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9370551..8ed51c5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.setting import AppSetting from app.models.trend import TrendScore from app.models.committee import Committee, CommitteeBill from app.models.user import User +from app.models.vote import BillVote, MemberVotePosition __all__ = [ "Bill", @@ -18,12 +19,14 @@ __all__ = [ "BillDocument", "BillBrief", "BillNote", + "BillVote", "Collection", "CollectionBill", "Follow", "Member", "MemberTrendScore", "MemberNewsArticle", + "MemberVotePosition", "NewsArticle", "NotificationEvent", "AppSetting", diff --git a/backend/app/models/vote.py b/backend/app/models/vote.py new file mode 100644 index 0000000..a645e95 --- /dev/null +++ b/backend/app/models/vote.py @@ -0,0 +1,53 @@ +from sqlalchemy import Column, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class BillVote(Base): + __tablename__ = "bill_votes" + + id = Column(Integer, primary_key=True, autoincrement=True) + bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False) + congress = Column(Integer, nullable=False) + chamber = Column(String(50), nullable=False) + session = Column(Integer, nullable=False) + roll_number = Column(Integer, nullable=False) + question = Column(Text) + description = Column(Text) + vote_date = Column(Date) + yeas = Column(Integer) + nays = Column(Integer) + not_voting = Column(Integer) + result = Column(String(200)) + source_url = Column(String) + fetched_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + positions = relationship("MemberVotePosition", back_populates="vote", cascade="all, delete-orphan") + + __table_args__ = ( + Index("ix_bill_votes_bill_id", "bill_id"), + UniqueConstraint("congress", "chamber", "session", "roll_number", name="uq_bill_votes_roll"), + ) + + +class MemberVotePosition(Base): + __tablename__ = "member_vote_positions" + + id = Column(Integer, primary_key=True, autoincrement=True) + vote_id = Column(Integer, ForeignKey("bill_votes.id", ondelete="CASCADE"), nullable=False) + bioguide_id = Column(String, ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True) + member_name = Column(String(200)) + party = Column(String(50)) + state = Column(String(10)) + position = Column(String(50), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + vote = relationship("BillVote", back_populates="positions") + + __table_args__ = ( + Index("ix_member_vote_positions_vote_id", "vote_id"), + Index("ix_member_vote_positions_bioguide_id", "bioguide_id"), + ) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 89cf270..25ab10b 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -337,3 +337,34 @@ class CollectionDetailSchema(CollectionSchema): class BriefShareResponse(BaseModel): brief: BriefSchema bill: BillSchema + + +# ── Votes ────────────────────────────────────────────────────────────────────── + +class MemberVotePositionSchema(BaseModel): + bioguide_id: Optional[str] = None + member_name: Optional[str] = None + party: Optional[str] = None + state: Optional[str] = None + position: str + + model_config = {"from_attributes": True} + + +class BillVoteSchema(BaseModel): + id: int + congress: int + chamber: str + session: int + roll_number: int + question: Optional[str] = None + description: Optional[str] = None + vote_date: Optional[date] = None + yeas: Optional[int] = None + nays: Optional[int] = None + not_voting: Optional[int] = None + result: Optional[str] = None + source_url: Optional[str] = None + positions: list[MemberVotePositionSchema] = [] + + model_config = {"from_attributes": True} diff --git a/backend/app/services/congress_api.py b/backend/app/services/congress_api.py index 774444f..4ffd538 100644 --- a/backend/app/services/congress_api.py +++ b/backend/app/services/congress_api.py @@ -90,6 +90,11 @@ def get_bill_text_versions(congress: int, bill_type: str, bill_number: int) -> d return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/text", {}) +def get_vote_detail(congress: int, chamber: str, session: int, roll_number: int) -> dict: + chamber_slug = "house" if chamber.lower() == "house" else "senate" + return _get(f"/vote/{congress}/{chamber_slug}/{session}/{roll_number}", {}) + + def get_members(offset: int = 0, limit: int = 250, current_member: bool = True) -> dict: params: dict = {"offset": offset, "limit": limit} if current_member: diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index 99054b1..70209c2 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -66,7 +66,9 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool: if not key: key = "referral" if payload.get("milestone_tier") == "referral" else "vote" - all_filters = prefs.get("alert_filters") or {} + all_filters = prefs.get("alert_filters") + if all_filters is None: + return True # user hasn't configured filters yet — send everything mode_filters = all_filters.get(follow_mode) or {} return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True))) diff --git a/backend/app/workers/vote_fetcher.py b/backend/app/workers/vote_fetcher.py new file mode 100644 index 0000000..c553359 --- /dev/null +++ b/backend/app/workers/vote_fetcher.py @@ -0,0 +1,239 @@ +""" +Vote fetcher — fetches roll-call vote data for bills. + +Roll-call votes are referenced in bill actions as recordedVotes objects. +Each recordedVote contains a direct URL to the source XML: + - House: https://clerk.house.gov/evs/{year}/roll{NNN}.xml + - Senate: https://www.senate.gov/legislative/LIS/roll_call_votes/... + +We fetch and parse that XML directly rather than going through a +Congress.gov API endpoint (which doesn't expose vote detail). + +Triggered on-demand from GET /api/bills/{bill_id}/votes when no votes +are stored yet. +""" +import logging +import xml.etree.ElementTree as ET +from datetime import date, datetime, timezone + +import requests + +from app.database import get_sync_db +from app.models.bill import Bill +from app.models.member import Member +from app.models.vote import BillVote, MemberVotePosition +from app.services.congress_api import get_bill_actions as _api_get_bill_actions +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + +_FETCH_TIMEOUT = 15 + + +def _parse_date(s) -> date | None: + if not s: + return None + try: + return date.fromisoformat(str(s)[:10]) + except Exception: + return None + + +def _fetch_xml(url: str) -> ET.Element: + resp = requests.get(url, timeout=_FETCH_TIMEOUT) + resp.raise_for_status() + return ET.fromstring(resp.content) + + +def _parse_house_xml(root: ET.Element) -> dict: + """Parse House Clerk roll-call XML (clerk.house.gov/evs/...).""" + meta = root.find("vote-metadata") + question = (meta.findtext("vote-question") or "").strip() if meta is not None else "" + result = (meta.findtext("vote-result") or "").strip() if meta is not None else "" + + totals = root.find(".//totals-by-vote") + yeas = int((totals.findtext("yea-total") or "0").strip()) if totals is not None else 0 + nays = int((totals.findtext("nay-total") or "0").strip()) if totals is not None else 0 + not_voting = int((totals.findtext("not-voting-total") or "0").strip()) if totals is not None else 0 + + members = [] + for rv in root.findall(".//recorded-vote"): + leg = rv.find("legislator") + if leg is None: + continue + members.append({ + "bioguide_id": leg.get("name-id"), + "member_name": (leg.text or "").strip(), + "party": leg.get("party"), + "state": leg.get("state"), + "position": (rv.findtext("vote") or "Not Voting").strip(), + }) + + return {"question": question, "result": result, "yeas": yeas, "nays": nays, + "not_voting": not_voting, "members": members} + + +def _parse_senate_xml(root: ET.Element) -> dict: + """Parse Senate LIS roll-call XML (senate.gov/legislative/LIS/...).""" + question = (root.findtext("vote_question_text") or root.findtext("question") or "").strip() + result = (root.findtext("vote_result_text") or "").strip() + + counts = root.find("vote_counts") + yeas = int((counts.findtext("yeas") or "0").strip()) if counts is not None else 0 + nays = int((counts.findtext("nays") or "0").strip()) if counts is not None else 0 + not_voting = int((counts.findtext("absent") or "0").strip()) if counts is not None else 0 + + members = [] + for m in root.findall(".//member"): + first = (m.findtext("first_name") or "").strip() + last = (m.findtext("last_name") or "").strip() + members.append({ + "bioguide_id": (m.findtext("bioguide_id") or "").strip() or None, + "member_name": f"{first} {last}".strip(), + "party": m.findtext("party"), + "state": m.findtext("state"), + "position": (m.findtext("vote_cast") or "Not Voting").strip(), + }) + + return {"question": question, "result": result, "yeas": yeas, "nays": nays, + "not_voting": not_voting, "members": members} + + +def _parse_vote_xml(url: str, chamber: str) -> dict: + root = _fetch_xml(url) + if chamber.lower() == "house": + return _parse_house_xml(root) + return _parse_senate_xml(root) + + +def _collect_recorded_votes(congress: int, bill_type: str, bill_number: int) -> list[dict]: + """Page through all bill actions and collect unique recordedVotes entries.""" + seen: set[tuple] = set() + recorded: list[dict] = [] + offset = 0 + + while True: + data = _api_get_bill_actions(congress, bill_type, bill_number, offset=offset) + actions = data.get("actions", []) + pagination = data.get("pagination", {}) + + for action in actions: + for rv in action.get("recordedVotes", []): + chamber = rv.get("chamber", "") + session = int(rv.get("sessionNumber") or rv.get("session") or 1) + roll_number = rv.get("rollNumber") + if not roll_number: + continue + roll_number = int(roll_number) + key = (chamber, session, roll_number) + if key not in seen: + seen.add(key) + recorded.append({ + "chamber": chamber, + "session": session, + "roll_number": roll_number, + "date": action.get("actionDate"), + "url": rv.get("url"), + }) + + total = pagination.get("count", 0) + offset += len(actions) + if offset >= total or not actions: + break + + return recorded + + +@celery_app.task(bind=True, name="app.workers.vote_fetcher.fetch_bill_votes") +def fetch_bill_votes(self, bill_id: str) -> dict: + """Fetch and store roll-call votes for a single bill.""" + db = get_sync_db() + try: + bill = db.get(Bill, bill_id) + if not bill: + return {"error": f"Bill {bill_id} not found"} + + recorded = _collect_recorded_votes(bill.congress_number, bill.bill_type, bill.bill_number) + + if not recorded: + logger.info(f"fetch_bill_votes({bill_id}): no recorded votes in actions") + return {"bill_id": bill_id, "stored": 0, "skipped": 0} + + now = datetime.now(timezone.utc) + stored = 0 + skipped = 0 + + # Cache known bioguide IDs to avoid N+1 member lookups + known_bioguides: set[str] = { + row[0] for row in db.query(Member.bioguide_id).all() + } + + for rv in recorded: + chamber = rv["chamber"] + session = rv["session"] + roll_number = rv["roll_number"] + source_url = rv.get("url") + + existing = ( + db.query(BillVote) + .filter_by( + congress=bill.congress_number, + chamber=chamber, + session=session, + roll_number=roll_number, + ) + .first() + ) + if existing: + skipped += 1 + continue + + if not source_url: + logger.warning(f"No URL for {chamber} roll {roll_number} — skipping") + continue + + try: + parsed = _parse_vote_xml(source_url, chamber) + except Exception as exc: + logger.warning(f"Could not parse vote XML {source_url}: {exc}") + continue + + bill_vote = BillVote( + bill_id=bill_id, + congress=bill.congress_number, + chamber=chamber, + session=session, + roll_number=roll_number, + question=parsed["question"], + description=None, + vote_date=_parse_date(rv.get("date")), + yeas=parsed["yeas"], + nays=parsed["nays"], + not_voting=parsed["not_voting"], + result=parsed["result"], + source_url=source_url, + fetched_at=now, + ) + db.add(bill_vote) + db.flush() + + for pos in parsed["members"]: + bioguide_id = pos.get("bioguide_id") + if bioguide_id and bioguide_id not in known_bioguides: + bioguide_id = None + db.add(MemberVotePosition( + vote_id=bill_vote.id, + bioguide_id=bioguide_id, + member_name=pos.get("member_name"), + party=pos.get("party"), + state=pos.get("state"), + position=pos.get("position") or "Not Voting", + )) + + db.commit() + stored += 1 + + logger.info(f"fetch_bill_votes({bill_id}): {stored} stored, {skipped} skipped") + return {"bill_id": bill_id, "stored": stored, "skipped": skipped} + finally: + db.close() diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx index c9eec91..e5550a8 100644 --- a/frontend/app/bills/[id]/page.tsx +++ b/frontend/app/bills/[id]/page.tsx @@ -9,6 +9,7 @@ import { BriefPanel } from "@/components/bills/BriefPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; import { NotesPanel } from "@/components/bills/NotesPanel"; import { ActionTimeline } from "@/components/bills/ActionTimeline"; +import { VotePanel } from "@/components/bills/VotePanel"; import { TrendChart } from "@/components/bills/TrendChart"; import { NewsPanel } from "@/components/bills/NewsPanel"; import { FollowButton } from "@/components/shared/FollowButton"; @@ -170,6 +171,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin latestActionDate={bill.latest_action_date} latestActionText={bill.latest_action_text} /> +
diff --git a/frontend/components/bills/VotePanel.tsx b/frontend/components/bills/VotePanel.tsx new file mode 100644 index 0000000..939458f --- /dev/null +++ b/frontend/components/bills/VotePanel.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ListChecks, ChevronDown, ChevronUp } from "lucide-react"; +import { billsAPI, followsAPI } from "@/lib/api"; +import { cn, formatDate, partyBadgeColor } from "@/lib/utils"; +import type { BillVote, MemberVotePosition } from "@/lib/types"; + +interface VotePanelProps { + billId: string; +} + +export function VotePanel({ billId }: VotePanelProps) { + const [expanded, setExpanded] = useState(true); + + const { data: votes, isLoading } = useQuery({ + queryKey: ["votes", billId], + queryFn: () => billsAPI.getVotes(billId), + staleTime: 5 * 60 * 1000, + }); + + const { data: follows } = useQuery({ + queryKey: ["follows"], + queryFn: () => followsAPI.list(), + retry: false, + throwOnError: false, + }); + + const followedMemberIds = new Set( + (follows || []) + .filter((f) => f.follow_type === "member") + .map((f) => f.follow_value) + ); + + if (isLoading || !votes || votes.length === 0) return null; + + return ( +
+ + + {expanded && ( +
+ {votes.map((vote) => ( + + ))} +
+ )} +
+ ); +} + +function VoteRow({ + vote, + followedMemberIds, +}: { + vote: BillVote; + followedMemberIds: Set; +}) { + const [showPositions, setShowPositions] = useState(false); + + const total = (vote.yeas ?? 0) + (vote.nays ?? 0) + (vote.not_voting ?? 0); + const yeaPct = total > 0 ? ((vote.yeas ?? 0) / total) * 100 : 0; + const nayPct = total > 0 ? ((vote.nays ?? 0) / total) * 100 : 0; + + const resultLower = (vote.result ?? "").toLowerCase(); + const passed = + resultLower.includes("pass") || + resultLower.includes("agreed") || + resultLower.includes("adopted") || + resultLower.includes("enacted"); + + const followedPositions: MemberVotePosition[] = vote.positions.filter( + (p) => p.bioguide_id && followedMemberIds.has(p.bioguide_id) + ); + + return ( +
+ {/* Header row */} +
+
+
+ {vote.result && ( + + {vote.result} + + )} + + {vote.chamber} Roll #{vote.roll_number} + {vote.vote_date && ` · ${formatDate(vote.vote_date)}`} + +
+ {vote.question && ( +

{vote.question}

+ )} +
+ {vote.source_url && ( + + Source ↗ + + )} +
+ + {/* Yea / Nay bar */} + {total > 0 && ( +
+
+
+
+
+
+ + {vote.yeas ?? "—"} Yea + + + {vote.nays ?? "—"} Nay + + {(vote.not_voting ?? 0) > 0 && ( + {vote.not_voting} Not Voting + )} +
+
+ )} + + {/* Followed member positions */} + {followedPositions.length > 0 && ( +
+ + {showPositions && ( +
+ {followedPositions.map((p, i) => ( +
+ + + {vote.chamber === "Senate" ? "Sen." : "Rep."} + + {p.member_name} + {p.party && ( + + {p.party} + + )} + {p.state && {p.state}} + + {p.position} + +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7f3f291..6a81fdf 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -3,6 +3,7 @@ import type { Bill, BillAction, BillDetail, + BillVote, BriefSchema, Collection, CollectionDetail, @@ -78,6 +79,8 @@ export const billsAPI = { apiClient.get(`/api/bills/${id}/news`).then((r) => r.data), getTrend: (id: string, days?: number) => apiClient.get(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data), + getVotes: (id: string) => + apiClient.get(`/api/bills/${id}/votes`).then((r) => r.data), generateDraft: (id: string, body: { stance: string; recipient: string; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 5243f01..3c147b1 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -199,6 +199,31 @@ export interface CollectionDetail extends Collection { bills: Bill[]; } +export interface MemberVotePosition { + bioguide_id?: string; + member_name?: string; + party?: string; + state?: string; + position: string; +} + +export interface BillVote { + id: number; + congress: number; + chamber: string; + session: number; + roll_number: number; + question?: string; + description?: string; + vote_date?: string; + yeas?: number; + nays?: number; + not_voting?: number; + result?: string; + source_url?: string; + positions: MemberVotePosition[]; +} + export interface NotificationEvent { id: number; bill_id: string;