feat: PocketVeto v1.0.0 — initial public release
Self-hosted US Congress monitoring platform with AI policy briefs, bill/member/topic follows, ntfy + RSS + email notifications, alignment scoring, collections, and draft-letter generator. Authored by: Jack Levy
This commit is contained in:
205
backend/alembic/versions/0001_initial_schema.py
Normal file
205
backend/alembic/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── members ──────────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"members",
|
||||
sa.Column("bioguide_id", sa.String(), primary_key=True),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("first_name", sa.String()),
|
||||
sa.Column("last_name", sa.String()),
|
||||
sa.Column("party", sa.String(10)),
|
||||
sa.Column("state", sa.String(5)),
|
||||
sa.Column("chamber", sa.String(10)),
|
||||
sa.Column("district", sa.String(10)),
|
||||
sa.Column("photo_url", sa.String()),
|
||||
sa.Column("official_url", sa.String()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# ── bills ─────────────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"bills",
|
||||
sa.Column("bill_id", sa.String(), primary_key=True),
|
||||
sa.Column("congress_number", sa.Integer(), nullable=False),
|
||||
sa.Column("bill_type", sa.String(10), nullable=False),
|
||||
sa.Column("bill_number", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.Text()),
|
||||
sa.Column("short_title", sa.Text()),
|
||||
sa.Column("sponsor_id", sa.String(), sa.ForeignKey("members.bioguide_id"), nullable=True),
|
||||
sa.Column("introduced_date", sa.Date()),
|
||||
sa.Column("latest_action_date", sa.Date()),
|
||||
sa.Column("latest_action_text", sa.Text()),
|
||||
sa.Column("status", sa.String(100)),
|
||||
sa.Column("chamber", sa.String(10)),
|
||||
sa.Column("congress_url", sa.String()),
|
||||
sa.Column("govtrack_url", sa.String()),
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("actions_fetched_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_bills_congress_number", "bills", ["congress_number"])
|
||||
op.create_index("ix_bills_latest_action_date", "bills", ["latest_action_date"])
|
||||
op.create_index("ix_bills_introduced_date", "bills", ["introduced_date"])
|
||||
op.create_index("ix_bills_chamber", "bills", ["chamber"])
|
||||
op.create_index("ix_bills_sponsor_id", "bills", ["sponsor_id"])
|
||||
|
||||
# Full-text search vector (tsvector generated column) — manual, not in autogenerate
|
||||
op.execute("""
|
||||
ALTER TABLE bills ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(short_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(latest_action_text, '')), 'C')
|
||||
) STORED
|
||||
""")
|
||||
op.execute("CREATE INDEX ix_bills_search_vector ON bills USING GIN(search_vector)")
|
||||
|
||||
# ── bill_actions ──────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"bill_actions",
|
||||
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("action_date", sa.Date()),
|
||||
sa.Column("action_text", sa.Text()),
|
||||
sa.Column("action_type", sa.String(100)),
|
||||
sa.Column("chamber", sa.String(10)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_bill_actions_bill_id", "bill_actions", ["bill_id"])
|
||||
op.create_index("ix_bill_actions_action_date", "bill_actions", ["action_date"])
|
||||
|
||||
# ── bill_documents ────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"bill_documents",
|
||||
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("doc_type", sa.String(50)),
|
||||
sa.Column("doc_version", sa.String(50)),
|
||||
sa.Column("govinfo_url", sa.String()),
|
||||
sa.Column("raw_text", sa.Text()),
|
||||
sa.Column("fetched_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_bill_documents_bill_id", "bill_documents", ["bill_id"])
|
||||
|
||||
# ── bill_briefs ───────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"bill_briefs",
|
||||
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("document_id", sa.Integer(), sa.ForeignKey("bill_documents.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("summary", sa.Text()),
|
||||
sa.Column("key_points", JSONB()),
|
||||
sa.Column("risks", JSONB()),
|
||||
sa.Column("deadlines", JSONB()),
|
||||
sa.Column("topic_tags", JSONB()),
|
||||
sa.Column("llm_provider", sa.String(50)),
|
||||
sa.Column("llm_model", sa.String(100)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_bill_briefs_bill_id", "bill_briefs", ["bill_id"])
|
||||
op.execute("CREATE INDEX ix_bill_briefs_topic_tags ON bill_briefs USING GIN(topic_tags)")
|
||||
|
||||
# ── committees ────────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"committees",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("committee_code", sa.String(20), unique=True, nullable=False),
|
||||
sa.Column("name", sa.String(500)),
|
||||
sa.Column("chamber", sa.String(10)),
|
||||
sa.Column("committee_type", sa.String(50)),
|
||||
)
|
||||
|
||||
# ── committee_bills ───────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"committee_bills",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("committee_id", sa.Integer(), sa.ForeignKey("committees.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("referral_date", sa.Date()),
|
||||
)
|
||||
op.create_index("ix_committee_bills_bill_id", "committee_bills", ["bill_id"])
|
||||
op.create_index("ix_committee_bills_committee_id", "committee_bills", ["committee_id"])
|
||||
|
||||
# ── news_articles ─────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"news_articles",
|
||||
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("source", sa.String(200)),
|
||||
sa.Column("headline", sa.Text()),
|
||||
sa.Column("url", sa.String(), unique=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("relevance_score", sa.Float(), default=0.0),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_news_articles_bill_id", "news_articles", ["bill_id"])
|
||||
op.create_index("ix_news_articles_published_at", "news_articles", ["published_at"])
|
||||
|
||||
# ── trend_scores ──────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"trend_scores",
|
||||
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("score_date", sa.Date(), nullable=False),
|
||||
sa.Column("newsapi_count", sa.Integer(), default=0),
|
||||
sa.Column("gnews_count", sa.Integer(), default=0),
|
||||
sa.Column("gtrends_score", sa.Float(), default=0.0),
|
||||
sa.Column("composite_score", sa.Float(), default=0.0),
|
||||
sa.UniqueConstraint("bill_id", "score_date", name="uq_trend_scores_bill_date"),
|
||||
)
|
||||
op.create_index("ix_trend_scores_bill_id", "trend_scores", ["bill_id"])
|
||||
op.create_index("ix_trend_scores_score_date", "trend_scores", ["score_date"])
|
||||
op.create_index("ix_trend_scores_composite", "trend_scores", ["composite_score"])
|
||||
|
||||
# ── follows ───────────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"follows",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("follow_type", sa.String(20), nullable=False),
|
||||
sa.Column("follow_value", sa.String(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("follow_type", "follow_value", name="uq_follows_type_value"),
|
||||
)
|
||||
|
||||
# ── app_settings ──────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"app_settings",
|
||||
sa.Column("key", sa.String(), primary_key=True),
|
||||
sa.Column("value", sa.String()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("app_settings")
|
||||
op.drop_table("follows")
|
||||
op.drop_table("trend_scores")
|
||||
op.drop_table("news_articles")
|
||||
op.drop_table("committee_bills")
|
||||
op.drop_table("committees")
|
||||
op.drop_table("bill_briefs")
|
||||
op.drop_table("bill_documents")
|
||||
op.drop_table("bill_actions")
|
||||
op.drop_table("bills")
|
||||
op.drop_table("members")
|
||||
30
backend/alembic/versions/0002_widen_chamber_party_columns.py
Normal file
30
backend/alembic/versions/0002_widen_chamber_party_columns.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""widen chamber and party columns
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-02-28 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column("members", "chamber", type_=sa.String(50))
|
||||
op.alter_column("members", "party", type_=sa.String(50))
|
||||
op.alter_column("bills", "chamber", type_=sa.String(50))
|
||||
op.alter_column("bill_actions", "chamber", type_=sa.String(50))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("bill_actions", "chamber", type_=sa.String(10))
|
||||
op.alter_column("bills", "chamber", type_=sa.String(10))
|
||||
op.alter_column("members", "party", type_=sa.String(10))
|
||||
op.alter_column("members", "chamber", type_=sa.String(10))
|
||||
26
backend/alembic/versions/0003_widen_member_state_district.py
Normal file
26
backend/alembic/versions/0003_widen_member_state_district.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""widen member state and district columns
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-03-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column("members", "state", type_=sa.String(50))
|
||||
op.alter_column("members", "district", type_=sa.String(50))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("members", "district", type_=sa.String(10))
|
||||
op.alter_column("members", "state", type_=sa.String(5))
|
||||
27
backend/alembic/versions/0004_add_brief_type.py
Normal file
27
backend/alembic/versions/0004_add_brief_type.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""add brief_type to bill_briefs
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-03-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: Union[str, None] = "0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"bill_briefs",
|
||||
sa.Column("brief_type", sa.String(20), nullable=False, server_default="full"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("bill_briefs", "brief_type")
|
||||
74
backend/alembic/versions/0005_add_users_and_user_follows.py
Normal file
74
backend/alembic/versions/0005_add_users_and_user_follows.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""add users table and user_id to follows
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-03-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: Union[str, None] = "0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Clear existing global follows — they have no user and cannot be migrated
|
||||
op.execute("DELETE FROM follows")
|
||||
|
||||
# 2. Create users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("email", sa.String(), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(), nullable=False),
|
||||
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("notification_prefs", JSONB(), nullable=False, server_default="{}"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
|
||||
# 3. Add user_id to follows (nullable first, then tighten after FK is set)
|
||||
op.add_column("follows", sa.Column("user_id", sa.Integer(), nullable=True))
|
||||
|
||||
# 4. FK constraint
|
||||
op.create_foreign_key(
|
||||
"fk_follows_user_id",
|
||||
"follows",
|
||||
"users",
|
||||
["user_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# 5. Drop old unique constraint and add user-scoped one
|
||||
op.drop_constraint("uq_follows_type_value", "follows", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_follows_user_type_value",
|
||||
"follows",
|
||||
["user_id", "follow_type", "follow_value"],
|
||||
)
|
||||
|
||||
# 6. Make user_id NOT NULL (table is empty so this is safe)
|
||||
op.alter_column("follows", "user_id", nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("follows", "user_id", nullable=True)
|
||||
op.drop_constraint("uq_follows_user_type_value", "follows", type_="unique")
|
||||
op.create_unique_constraint("uq_follows_type_value", "follows", ["follow_type", "follow_value"])
|
||||
op.drop_constraint("fk_follows_user_id", "follows", type_="foreignkey")
|
||||
op.drop_column("follows", "user_id")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
21
backend/alembic/versions/0006_add_brief_govinfo_url.py
Normal file
21
backend/alembic/versions/0006_add_brief_govinfo_url.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""add govinfo_url to bill_briefs
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-02-28
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0006"
|
||||
down_revision = "0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("bill_briefs", sa.Column("govinfo_url", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("bill_briefs", "govinfo_url")
|
||||
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")
|
||||
54
backend/alembic/versions/0008_add_member_interest_tables.py
Normal file
54
backend/alembic/versions/0008_add_member_interest_tables.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""add member trend scores and news articles tables
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"member_trend_scores",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("member_id", sa.String(), nullable=False),
|
||||
sa.Column("score_date", sa.Date(), nullable=False),
|
||||
sa.Column("newsapi_count", sa.Integer(), nullable=True, default=0),
|
||||
sa.Column("gnews_count", sa.Integer(), nullable=True, default=0),
|
||||
sa.Column("gtrends_score", sa.Float(), nullable=True, default=0.0),
|
||||
sa.Column("composite_score", sa.Float(), nullable=True, default=0.0),
|
||||
sa.ForeignKeyConstraint(["member_id"], ["members.bioguide_id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("member_id", "score_date", name="uq_member_trend_scores_member_date"),
|
||||
)
|
||||
op.create_index("ix_member_trend_scores_member_id", "member_trend_scores", ["member_id"])
|
||||
op.create_index("ix_member_trend_scores_score_date", "member_trend_scores", ["score_date"])
|
||||
op.create_index("ix_member_trend_scores_composite", "member_trend_scores", ["composite_score"])
|
||||
|
||||
op.create_table(
|
||||
"member_news_articles",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("member_id", sa.String(), nullable=False),
|
||||
sa.Column("source", sa.String(200), nullable=True),
|
||||
sa.Column("headline", sa.Text(), nullable=True),
|
||||
sa.Column("url", sa.String(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("relevance_score", sa.Float(), nullable=True, default=0.0),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["member_id"], ["members.bioguide_id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("member_id", "url", name="uq_member_news_member_url"),
|
||||
)
|
||||
op.create_index("ix_member_news_articles_member_id", "member_news_articles", ["member_id"])
|
||||
op.create_index("ix_member_news_articles_published_at", "member_news_articles", ["published_at"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("member_news_articles")
|
||||
op.drop_table("member_trend_scores")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""fix news_articles url uniqueness to per-bill scope
|
||||
|
||||
Previously url was globally unique, meaning the same article could only
|
||||
be stored for one bill. This changes it to (bill_id, url) unique so the
|
||||
same article can appear in multiple bills' news panels.
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Drop the old global unique constraint on url
|
||||
op.drop_constraint("news_articles_url_key", "news_articles", type_="unique")
|
||||
# Add per-bill unique constraint
|
||||
op.create_unique_constraint("uq_news_articles_bill_url", "news_articles", ["bill_id", "url"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint("uq_news_articles_bill_url", "news_articles", type_="unique")
|
||||
op.create_unique_constraint("news_articles_url_key", "news_articles", ["url"])
|
||||
56
backend/alembic/versions/0010_backfill_bill_congress_urls.py
Normal file
56
backend/alembic/versions/0010_backfill_bill_congress_urls.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""backfill bill congress_urls with proper public URLs
|
||||
|
||||
Bills stored before this fix have congress_url set to the API endpoint
|
||||
(https://api.congress.gov/v3/bill/...) instead of the public page
|
||||
(https://www.congress.gov/bill/...). This migration rebuilds all URLs
|
||||
from the congress_number, bill_type, and bill_number columns which are
|
||||
already stored correctly.
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_BILL_TYPE_SLUG = {
|
||||
"hr": "house-bill",
|
||||
"s": "senate-bill",
|
||||
"hjres": "house-joint-resolution",
|
||||
"sjres": "senate-joint-resolution",
|
||||
"hres": "house-resolution",
|
||||
"sres": "senate-resolution",
|
||||
"hconres": "house-concurrent-resolution",
|
||||
"sconres": "senate-concurrent-resolution",
|
||||
}
|
||||
|
||||
|
||||
def _ordinal(n: int) -> str:
|
||||
if 11 <= n % 100 <= 13:
|
||||
return f"{n}th"
|
||||
suffixes = {1: "st", 2: "nd", 3: "rd"}
|
||||
return f"{n}{suffixes.get(n % 10, 'th')}"
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
bills = conn.execute(
|
||||
sa.text("SELECT bill_id, congress_number, bill_type, bill_number FROM bills")
|
||||
).fetchall()
|
||||
for bill in bills:
|
||||
slug = _BILL_TYPE_SLUG.get(bill.bill_type, bill.bill_type)
|
||||
url = f"https://www.congress.gov/bill/{_ordinal(bill.congress_number)}-congress/{slug}/{bill.bill_number}"
|
||||
conn.execute(
|
||||
sa.text("UPDATE bills SET congress_url = :url WHERE bill_id = :bill_id"),
|
||||
{"url": url, "bill_id": bill.bill_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Original API URLs cannot be recovered — no-op
|
||||
pass
|
||||
39
backend/alembic/versions/0011_add_notifications.py
Normal file
39
backend/alembic/versions/0011_add_notifications.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""add notifications: rss_token on users, notification_events table
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("rss_token", sa.String(), nullable=True))
|
||||
op.create_index("ix_users_rss_token", "users", ["rss_token"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"notification_events",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
sa.Column("dispatched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_notification_events_user_id", "notification_events", ["user_id"])
|
||||
op.create_index("ix_notification_events_dispatched_at", "notification_events", ["dispatched_at"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("notification_events")
|
||||
op.drop_index("ix_users_rss_token", table_name="users")
|
||||
op.drop_column("users", "rss_token")
|
||||
32
backend/alembic/versions/0012_dedupe_bill_actions_unique.py
Normal file
32
backend/alembic/versions/0012_dedupe_bill_actions_unique.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Deduplicate bill_actions and add unique constraint on (bill_id, action_date, action_text)
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Remove duplicate rows keeping the lowest id for each (bill_id, action_date, action_text)
|
||||
op.execute("""
|
||||
DELETE FROM bill_actions a
|
||||
USING bill_actions b
|
||||
WHERE a.id > b.id
|
||||
AND a.bill_id = b.bill_id
|
||||
AND a.action_date IS NOT DISTINCT FROM b.action_date
|
||||
AND a.action_text IS NOT DISTINCT FROM b.action_text
|
||||
""")
|
||||
op.create_unique_constraint(
|
||||
"uq_bill_actions_bill_date_text",
|
||||
"bill_actions",
|
||||
["bill_id", "action_date", "action_text"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint("uq_bill_actions_bill_date_text", "bill_actions", type_="unique")
|
||||
23
backend/alembic/versions/0013_add_follow_mode.py
Normal file
23
backend/alembic/versions/0013_add_follow_mode.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Add follow_mode column to follows table
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"follows",
|
||||
sa.Column("follow_mode", sa.String(20), nullable=False, server_default="neutral"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("follows", "follow_mode")
|
||||
32
backend/alembic/versions/0014_add_bill_notes.py
Normal file
32
backend/alembic/versions/0014_add_bill_notes.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Add bill_notes table
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0014"
|
||||
down_revision = "0013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"bill_notes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "bill_id", name="uq_bill_notes_user_bill"),
|
||||
)
|
||||
op.create_index("ix_bill_notes_user_id", "bill_notes", ["user_id"])
|
||||
op.create_index("ix_bill_notes_bill_id", "bill_notes", ["bill_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("bill_notes")
|
||||
52
backend/alembic/versions/0015_add_collections.py
Normal file
52
backend/alembic/versions/0015_add_collections.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Add collections and collection_bills tables
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0015"
|
||||
down_revision = "0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"collections",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("slug", sa.String(120), nullable=False),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"share_token",
|
||||
postgresql.UUID(as_uuid=False),
|
||||
nullable=False,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("share_token", name="uq_collections_share_token"),
|
||||
sa.UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
|
||||
)
|
||||
op.create_index("ix_collections_user_id", "collections", ["user_id"])
|
||||
op.create_index("ix_collections_share_token", "collections", ["share_token"])
|
||||
|
||||
op.create_table(
|
||||
"collection_bills",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("collection_id", sa.Integer(), sa.ForeignKey("collections.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("bill_id", sa.String(), sa.ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("added_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
|
||||
)
|
||||
op.create_index("ix_collection_bills_collection_id", "collection_bills", ["collection_id"])
|
||||
op.create_index("ix_collection_bills_bill_id", "collection_bills", ["bill_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("collection_bills")
|
||||
op.drop_table("collections")
|
||||
33
backend/alembic/versions/0016_add_brief_share_token.py
Normal file
33
backend/alembic/versions/0016_add_brief_share_token.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Add share_token to bill_briefs
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0016"
|
||||
down_revision = "0015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"bill_briefs",
|
||||
sa.Column(
|
||||
"share_token",
|
||||
postgresql.UUID(as_uuid=False),
|
||||
nullable=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
)
|
||||
op.create_unique_constraint("uq_brief_share_token", "bill_briefs", ["share_token"])
|
||||
op.create_index("ix_brief_share_token", "bill_briefs", ["share_token"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_brief_share_token", "bill_briefs")
|
||||
op.drop_constraint("uq_brief_share_token", "bill_briefs")
|
||||
op.drop_column("bill_briefs", "share_token")
|
||||
63
backend/alembic/versions/0017_add_bill_votes.py
Normal file
63
backend/alembic/versions/0017_add_bill_votes.py
Normal file
@@ -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")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Add bill_category, cosponsors, and member effectiveness score columns
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0018"
|
||||
down_revision = "0017"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Bill additions
|
||||
op.add_column("bills", sa.Column("bill_category", sa.String(20), nullable=True))
|
||||
op.add_column("bills", sa.Column("cosponsors_fetched_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Co-sponsors table
|
||||
op.create_table(
|
||||
"bill_cosponsors",
|
||||
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("bioguide_id", sa.String, sa.ForeignKey("members.bioguide_id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("name", sa.String(200)),
|
||||
sa.Column("party", sa.String(50)),
|
||||
sa.Column("state", sa.String(10)),
|
||||
sa.Column("sponsored_date", sa.Date, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_bill_cosponsors_bill_id", "bill_cosponsors", ["bill_id"])
|
||||
op.create_index("ix_bill_cosponsors_bioguide_id", "bill_cosponsors", ["bioguide_id"])
|
||||
# Partial unique index — prevents duplicates for known members, allows multiple nulls
|
||||
op.create_index(
|
||||
"uq_bill_cosponsors_bill_member",
|
||||
"bill_cosponsors",
|
||||
["bill_id", "bioguide_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("bioguide_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# Member effectiveness columns
|
||||
op.add_column("members", sa.Column("effectiveness_score", sa.Float, nullable=True))
|
||||
op.add_column("members", sa.Column("effectiveness_percentile", sa.Float, nullable=True))
|
||||
op.add_column("members", sa.Column("effectiveness_tier", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("members", "effectiveness_tier")
|
||||
op.drop_column("members", "effectiveness_percentile")
|
||||
op.drop_column("members", "effectiveness_score")
|
||||
op.drop_index("uq_bill_cosponsors_bill_member", "bill_cosponsors")
|
||||
op.drop_index("ix_bill_cosponsors_bioguide_id", "bill_cosponsors")
|
||||
op.drop_index("ix_bill_cosponsors_bill_id", "bill_cosponsors")
|
||||
op.drop_table("bill_cosponsors")
|
||||
op.drop_column("bills", "cosponsors_fetched_at")
|
||||
op.drop_column("bills", "bill_category")
|
||||
22
backend/alembic/versions/0019_add_email_unsubscribe_token.py
Normal file
22
backend/alembic/versions/0019_add_email_unsubscribe_token.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Add email_unsubscribe_token to users
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0019"
|
||||
down_revision = "0018"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("email_unsubscribe_token", sa.String(64), nullable=True))
|
||||
op.create_index("ix_users_email_unsubscribe_token", "users", ["email_unsubscribe_token"], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_users_email_unsubscribe_token", table_name="users")
|
||||
op.drop_column("users", "email_unsubscribe_token")
|
||||
Reference in New Issue
Block a user