feat: per-user notifications (ntfy + RSS), deduplicated actions, backfill task

Notifications:
- New /notifications page accessible to all users (ntfy + RSS config)
- ntfy now supports no-auth, Bearer token, and HTTP Basic auth (for ACL-protected self-hosted servers)
- RSS enabled/disabled independently of ntfy; token auto-generated on first GET
- Notification settings removed from admin-only Settings page; replaced with link card
- Sidebar adds Notifications nav link for all users
- notification_dispatcher.py: fan-out now marks RSS events dispatched independently

Action history:
- Migration 0012: deduplicates existing bill_actions rows and adds UNIQUE(bill_id, action_date, action_text)
- congress_poller.py: replaces existence-check inserts with ON CONFLICT DO NOTHING (race-condition safe)
- Added backfill_all_bill_actions task (no date filter) + admin endpoint POST /backfill-all-actions

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 12:04:13 -05:00
parent 91790fd798
commit 2e2fefb795
22 changed files with 1006 additions and 164 deletions

View File

@@ -76,3 +76,89 @@
- [ ] **Source Viewer Option B** — in-app bill text viewer with cited passage highlighted and scroll-to-anchor. Deferred pending UX review of Option A (GovInfo link). - [ ] **Source Viewer Option B** — in-app bill text viewer with cited passage highlighted and scroll-to-anchor. Deferred pending UX review of Option A (GovInfo link).
- [ ] **Raw Diff Panel** — Python `difflib` diff between stored document versions, shown as collapsible "Raw Changes" below amendment brief. Zero API calls. Deferred — AI amendment brief is the primary "what changed" story. - [ ] **Raw Diff Panel** — Python `difflib` diff between stored document versions, shown as collapsible "Raw Changes" below amendment brief. Zero API calls. Deferred — AI amendment brief is the primary "what changed" story.
- [ ] **Shareable Collection Subscriptions** — "Follow this collection" mechanic so other users can subscribe to a public collection and get its bills added to their feed. - [ ] **Shareable Collection Subscriptions** — "Follow this collection" mechanic so other users can subscribe to a public collection and get its bills added to their feed.
- [ ] Pocket Veto mode (follow stance) — toggle on a bill to treat it as “I dont want this to pass”; adds to watchlist and triggers milestone alerts (committee report-out, calendared, vote scheduled, passed chamber, etc.)
- [ ] Pocket Veto notification rules — alert only on advancement milestones + failure outcomes (failed committee / failed floor / stalled)
- [ ] Follow modes — support Neutral (normal follow) + Pocket Veto now; optional Pocket Boost later
- [ ] UI: FollowButton becomes FollowMode selector (Neutral / Pocket Veto) with explanation tooltip
### PocketVeto function
#### How it should work (so its useful and not cringey)
Instead of “follow/unfollow,” each bill gets a **Follow Mode**:
- **Follow** (neutral): “Keep me posted on meaningful changes.”
- **Pocket Veto** (oppose): “Alert me if this bill is advancing toward passage.”
- (Optional later) **Pocket Boost** (support): “Alert me when action is needed / when its in trouble.” also suggest an action the user can take to let their representatives know that you support this bill.
For Pocket Veto specifically, the key is **threshold alerts**, not spam:
- **Committee referral**
- **Committee hearing scheduled**
- **Markup scheduled**
- **Reported out of committee**
- **Placed on calendar**
- **Floor vote scheduled**
- **Passed chamber**
- **Conference / reconciliation activity**
- **Sent to President**
- **Signed / Vetoed**
And the “failed” side:
- **Failed in committee**
- **Failed floor vote**
- **Stalled** (no action for X days while similar bills move)
#### Why its valuable for “normal people”
Most people dont want to follow politics continuously. They want:
- “Tell me if the bad thing is about to happen.”
Thats exactly what Pocket Veto mode does.
#### Guardrail to keep it non-partisan / non-toxic
Make it explicit in UI copy:
- Its a **personal alert preference**, not a moral label.
- It doesnt publish your stance unless you share it.
#### Data model addition (simple)
Add fields to `follows` (or a new table):
- `follow_mode`: `neutral | pocket_veto | pocket_boost`
- `alert_sensitivity`: `low | medium | high` (optional)
Then alert rules can be:
- neutral: material changes
- pocket_veto: only “advancing toward passage” milestones
- pocket_boost: “action points” + milestones

View 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

View 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")

View 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")

View File

@@ -130,6 +130,10 @@ async def get_stats(
WHERE bb.id IS NULL AND bd.raw_text IS NOT NULL WHERE bb.id IS NULL AND bd.raw_text IS NOT NULL
""") """)
)).scalar() )).scalar()
# Bills that have never had their action history fetched
bills_missing_actions = (await db.execute(
text("SELECT COUNT(*) FROM bills WHERE actions_fetched_at IS NULL")
)).scalar()
return { return {
"total_bills": total_bills, "total_bills": total_bills,
"docs_fetched": docs_fetched, "docs_fetched": docs_fetched,
@@ -141,6 +145,7 @@ async def get_stats(
"pending_llm": pending_llm, "pending_llm": pending_llm,
"bills_missing_sponsor": bills_missing_sponsor, "bills_missing_sponsor": bills_missing_sponsor,
"bills_missing_metadata": bills_missing_metadata, "bills_missing_metadata": bills_missing_metadata,
"bills_missing_actions": bills_missing_actions,
"remaining": total_bills - total_briefs, "remaining": total_bills - total_briefs,
} }
@@ -183,6 +188,14 @@ async def trigger_fetch_actions(current_user: User = Depends(get_current_admin))
return {"task_id": task.id, "status": "queued"} return {"task_id": task.id, "status": "queued"}
@router.post("/backfill-all-actions")
async def backfill_all_actions(current_user: User = Depends(get_current_admin)):
"""Queue action fetches for every bill that has never had actions fetched."""
from app.workers.congress_poller import backfill_all_bill_actions
task = backfill_all_bill_actions.delay()
return {"task_id": task.id, "status": "queued"}
@router.post("/backfill-metadata") @router.post("/backfill-metadata")
async def backfill_metadata(current_user: User = Depends(get_current_admin)): async def backfill_metadata(current_user: User = Depends(get_current_admin)):
"""Fill in null introduced_date, congress_url, chamber for existing bills.""" """Fill in null introduced_date, congress_url, chamber for existing bills."""

View File

@@ -69,12 +69,11 @@ async def get_member(bioguide_id: str, db: AsyncSession = Depends(get_db)):
if not member: if not member:
raise HTTPException(status_code=404, detail="Member not found") raise HTTPException(status_code=404, detail="Member not found")
# Kick off member interest scoring on first view (non-blocking) # Kick off member interest on first view — single combined task avoids duplicate API calls
if member.detail_fetched is None: if member.detail_fetched is None:
try: try:
from app.workers.member_interest import fetch_member_news, calculate_member_trend_score from app.workers.member_interest import sync_member_interest
fetch_member_news.delay(bioguide_id) sync_member_interest.delay(bioguide_id)
calculate_member_trend_score.delay(bioguide_id)
except Exception: except Exception:
pass pass

View File

@@ -0,0 +1,138 @@
"""
Notifications API — user notification settings and per-user RSS feed.
"""
import secrets
from xml.etree.ElementTree import Element, SubElement, tostring
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user
from app.database import get_db
from app.models.notification import NotificationEvent
from app.models.user import User
from app.schemas.schemas import NotificationSettingsResponse, NotificationSettingsUpdate
router = APIRouter()
_EVENT_LABELS = {
"new_document": "New Bill Text",
"new_amendment": "Amendment Filed",
"bill_updated": "Bill Updated",
}
def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettingsResponse:
return NotificationSettingsResponse(
ntfy_topic_url=prefs.get("ntfy_topic_url", ""),
ntfy_auth_method=prefs.get("ntfy_auth_method", "none"),
ntfy_token=prefs.get("ntfy_token", ""),
ntfy_username=prefs.get("ntfy_username", ""),
ntfy_password=prefs.get("ntfy_password", ""),
ntfy_enabled=prefs.get("ntfy_enabled", False),
rss_enabled=prefs.get("rss_enabled", False),
rss_token=rss_token,
)
@router.get("/settings", response_model=NotificationSettingsResponse)
async def get_notification_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user = await db.get(User, current_user.id)
# Auto-generate RSS token on first visit so the feed URL is always available
if not user.rss_token:
user.rss_token = secrets.token_urlsafe(32)
await db.commit()
await db.refresh(user)
return _prefs_to_response(user.notification_prefs or {}, user.rss_token)
@router.put("/settings", response_model=NotificationSettingsResponse)
async def update_notification_settings(
body: NotificationSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user = await db.get(User, current_user.id)
prefs = dict(user.notification_prefs or {})
if body.ntfy_topic_url is not None:
prefs["ntfy_topic_url"] = body.ntfy_topic_url.strip()
if body.ntfy_auth_method is not None:
prefs["ntfy_auth_method"] = body.ntfy_auth_method
if body.ntfy_token is not None:
prefs["ntfy_token"] = body.ntfy_token.strip()
if body.ntfy_username is not None:
prefs["ntfy_username"] = body.ntfy_username.strip()
if body.ntfy_password is not None:
prefs["ntfy_password"] = body.ntfy_password.strip()
if body.ntfy_enabled is not None:
prefs["ntfy_enabled"] = body.ntfy_enabled
if body.rss_enabled is not None:
prefs["rss_enabled"] = body.rss_enabled
user.notification_prefs = prefs
if not user.rss_token:
user.rss_token = secrets.token_urlsafe(32)
await db.commit()
await db.refresh(user)
return _prefs_to_response(user.notification_prefs or {}, user.rss_token)
@router.post("/settings/rss-reset", response_model=NotificationSettingsResponse)
async def reset_rss_token(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Regenerate the RSS token, invalidating the old feed URL."""
user = await db.get(User, current_user.id)
user.rss_token = secrets.token_urlsafe(32)
await db.commit()
await db.refresh(user)
return _prefs_to_response(user.notification_prefs or {}, user.rss_token)
@router.get("/feed/{rss_token}.xml", include_in_schema=False)
async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)):
"""Public tokenized RSS feed — no auth required."""
result = await db.execute(select(User).where(User.rss_token == rss_token))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Feed not found")
events_result = await db.execute(
select(NotificationEvent)
.where(NotificationEvent.user_id == user.id)
.order_by(NotificationEvent.created_at.desc())
.limit(50)
)
events = events_result.scalars().all()
return Response(content=_build_rss(events), media_type="application/rss+xml")
def _build_rss(events: list) -> bytes:
rss = Element("rss", version="2.0")
channel = SubElement(rss, "channel")
SubElement(channel, "title").text = "PocketVeto — Bill Alerts"
SubElement(channel, "description").text = "Updates on your followed bills"
SubElement(channel, "language").text = "en-us"
for event in events:
payload = event.payload or {}
item = SubElement(channel, "item")
label = _EVENT_LABELS.get(event.event_type, "Update")
bill_label = payload.get("bill_label", event.bill_id.upper())
SubElement(item, "title").text = f"{label}: {bill_label}{payload.get('bill_title', '')}"
SubElement(item, "description").text = payload.get("brief_summary", "")
if payload.get("bill_url"):
SubElement(item, "link").text = payload["bill_url"]
SubElement(item, "pubDate").text = event.created_at.strftime("%a, %d %b %Y %H:%M:%S +0000")
SubElement(item, "guid").text = str(event.id)
return tostring(rss, encoding="unicode").encode("utf-8")

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications
from app.config import settings as config from app.config import settings as config
app = FastAPI( app = FastAPI(
@@ -27,3 +27,4 @@ app.include_router(search.router, prefix="/api/search", tags=["search"])
app.include_router(settings.router, prefix="/api/settings", tags=["settings"]) app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(health.router, prefix="/api/health", tags=["health"]) app.include_router(health.router, prefix="/api/health", tags=["health"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])

View File

@@ -4,6 +4,7 @@ from app.models.follow import Follow
from app.models.member import Member from app.models.member import Member
from app.models.member_interest import MemberTrendScore, MemberNewsArticle from app.models.member_interest import MemberTrendScore, MemberNewsArticle
from app.models.news import NewsArticle from app.models.news import NewsArticle
from app.models.notification import NotificationEvent
from app.models.setting import AppSetting from app.models.setting import AppSetting
from app.models.trend import TrendScore from app.models.trend import TrendScore
from app.models.committee import Committee, CommitteeBill from app.models.committee import Committee, CommitteeBill
@@ -19,6 +20,7 @@ __all__ = [
"MemberTrendScore", "MemberTrendScore",
"MemberNewsArticle", "MemberNewsArticle",
"NewsArticle", "NewsArticle",
"NotificationEvent",
"AppSetting", "AppSetting",
"TrendScore", "TrendScore",
"Committee", "Committee",

View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class NotificationEvent(Base):
__tablename__ = "notification_events"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False)
# new_document | new_amendment | bill_updated
event_type = Column(String(50), nullable=False)
# {bill_title, bill_label, brief_summary, bill_url}
payload = Column(JSONB)
created_at = Column(DateTime(timezone=True), server_default=func.now())
dispatched_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", back_populates="notification_events")
__table_args__ = (
Index("ix_notification_events_user_id", "user_id"),
Index("ix_notification_events_dispatched_at", "dispatched_at"),
)

View File

@@ -14,6 +14,8 @@ class User(Base):
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
is_admin = Column(Boolean, nullable=False, default=False) is_admin = Column(Boolean, nullable=False, default=False)
notification_prefs = Column(JSONB, nullable=False, default=dict) notification_prefs = Column(JSONB, nullable=False, default=dict)
rss_token = Column(String, unique=True, nullable=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan") follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan")
notification_events = relationship("NotificationEvent", back_populates="user", cascade="all, delete-orphan")

View File

@@ -8,8 +8,12 @@ from pydantic import BaseModel
class NotificationSettingsResponse(BaseModel): class NotificationSettingsResponse(BaseModel):
ntfy_topic_url: str = "" ntfy_topic_url: str = ""
ntfy_auth_method: str = "none" # none | token | basic
ntfy_token: str = "" ntfy_token: str = ""
ntfy_username: str = ""
ntfy_password: str = ""
ntfy_enabled: bool = False ntfy_enabled: bool = False
rss_enabled: bool = False
rss_token: Optional[str] = None rss_token: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -17,8 +21,12 @@ class NotificationSettingsResponse(BaseModel):
class NotificationSettingsUpdate(BaseModel): class NotificationSettingsUpdate(BaseModel):
ntfy_topic_url: Optional[str] = None ntfy_topic_url: Optional[str] = None
ntfy_auth_method: Optional[str] = None
ntfy_token: Optional[str] = None ntfy_token: Optional[str] = None
ntfy_username: Optional[str] = None
ntfy_password: Optional[str] = None
ntfy_enabled: Optional[bool] = None ntfy_enabled: Optional[bool] = None
rss_enabled: Optional[bool] = None
T = TypeVar("T") T = TypeVar("T")

View File

@@ -15,6 +15,30 @@ from app.config import settings
BASE_URL = "https://api.congress.gov/v3" BASE_URL = "https://api.congress.gov/v3"
_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 _congress_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 build_bill_public_url(congress: int, bill_type: str, bill_number: int) -> str:
"""Return the public congress.gov page URL for a bill (not the API endpoint)."""
slug = _BILL_TYPE_SLUG.get(bill_type.lower(), bill_type.lower())
return f"https://www.congress.gov/bill/{_congress_ordinal(congress)}-congress/{slug}/{bill_number}"
def _get_current_congress() -> int: def _get_current_congress() -> int:
"""Calculate the current Congress number. 119th started Jan 3, 2025.""" """Calculate the current Congress number. 119th started Jan 3, 2025."""
@@ -98,7 +122,7 @@ def parse_bill_from_api(data: dict, congress: int) -> dict:
"latest_action_text": latest_action.get("text"), "latest_action_text": latest_action.get("text"),
"status": latest_action.get("text", "")[:100] if latest_action.get("text") else None, "status": latest_action.get("text", "")[:100] if latest_action.get("text") else None,
"chamber": "House" if bill_type.startswith("h") else "Senate", "chamber": "House" if bill_type.startswith("h") else "Senate",
"congress_url": data.get("url"), "congress_url": build_bill_public_url(congress, bill_type, bill_number),
} }

View File

@@ -7,10 +7,11 @@ News correlation service.
import logging import logging
import time import time
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import Optional from typing import Optional
import feedparser import feedparser
import redis
import requests import requests
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity import retry, stop_after_attempt, wait_exponential
@@ -22,6 +23,34 @@ NEWSAPI_BASE = "https://newsapi.org/v2"
GOOGLE_NEWS_RSS = "https://news.google.com/rss/search" GOOGLE_NEWS_RSS = "https://news.google.com/rss/search"
NEWSAPI_DAILY_LIMIT = 95 # Leave 5 as buffer NEWSAPI_DAILY_LIMIT = 95 # Leave 5 as buffer
_NEWSAPI_REDIS_PREFIX = "newsapi:daily_calls:"
def _newsapi_redis():
return redis.from_url(settings.REDIS_URL, decode_responses=True)
def _newsapi_quota_ok() -> bool:
"""Return True if we have quota remaining for today."""
try:
key = f"{_NEWSAPI_REDIS_PREFIX}{date.today().isoformat()}"
used = int(_newsapi_redis().get(key) or 0)
return used < NEWSAPI_DAILY_LIMIT
except Exception:
return True # Don't block on Redis errors
def _newsapi_record_call():
try:
r = _newsapi_redis()
key = f"{_NEWSAPI_REDIS_PREFIX}{date.today().isoformat()}"
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 90000) # 25 hours — expires safely after midnight
pipe.execute()
except Exception:
pass
@retry(stop=stop_after_attempt(2), wait=wait_exponential(min=1, max=5)) @retry(stop=stop_after_attempt(2), wait=wait_exponential(min=1, max=5))
def _newsapi_get(endpoint: str, params: dict) -> dict: def _newsapi_get(endpoint: str, params: dict) -> dict:
@@ -51,6 +80,9 @@ def fetch_newsapi_articles(query: str, days: int = 30) -> list[dict]:
"""Fetch articles from NewsAPI.org. Returns empty list if quota is exhausted or key not set.""" """Fetch articles from NewsAPI.org. Returns empty list if quota is exhausted or key not set."""
if not settings.NEWSAPI_KEY: if not settings.NEWSAPI_KEY:
return [] return []
if not _newsapi_quota_ok():
logger.warning("NewsAPI daily quota exhausted — skipping fetch")
return []
try: try:
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
data = _newsapi_get("everything", { data = _newsapi_get("everything", {
@@ -60,6 +92,7 @@ def fetch_newsapi_articles(query: str, days: int = 30) -> list[dict]:
"pageSize": 10, "pageSize": 10,
"from": from_date, "from": from_date,
}) })
_newsapi_record_call()
articles = data.get("articles", []) articles = data.get("articles", [])
return [ return [
{ {

View File

@@ -10,6 +10,7 @@ import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.database import get_sync_db from app.database import get_sync_db
from app.models import Bill, BillAction, Member, AppSetting from app.models import Bill, BillAction, Member, AppSetting
@@ -227,30 +228,15 @@ def fetch_bill_actions(self, bill_id: str):
break break
for action in actions_data: for action in actions_data:
action_date_str = action.get("actionDate") stmt = pg_insert(BillAction.__table__).values(
action_text = action.get("text", "")
action_type = action.get("type")
chamber = action.get("chamber")
# Idempotency check: skip if (bill_id, action_date, action_text) exists
exists = (
db.query(BillAction)
.filter(
BillAction.bill_id == bill_id,
BillAction.action_date == action_date_str,
BillAction.action_text == action_text,
)
.first()
)
if not exists:
db.add(BillAction(
bill_id=bill_id, bill_id=bill_id,
action_date=action_date_str, action_date=action.get("actionDate"),
action_text=action_text, action_text=action.get("text", ""),
action_type=action_type, action_type=action.get("type"),
chamber=chamber, chamber=action.get("chamber"),
)) ).on_conflict_do_nothing(constraint="uq_bill_actions_bill_date_text")
inserted += 1 result = db.execute(stmt)
inserted += result.rowcount
db.commit() db.commit()
offset += 250 offset += 250
@@ -297,6 +283,28 @@ def fetch_actions_for_active_bills(self):
db.close() db.close()
@celery_app.task(bind=True, name="app.workers.congress_poller.backfill_all_bill_actions")
def backfill_all_bill_actions(self):
"""One-time backfill: enqueue action fetches for every bill that has never had actions fetched."""
db = get_sync_db()
try:
bills = (
db.query(Bill)
.filter(Bill.actions_fetched_at.is_(None))
.order_by(Bill.latest_action_date.desc())
.all()
)
queued = 0
for bill in bills:
fetch_bill_actions.delay(bill.bill_id)
queued += 1
time.sleep(0.05) # ~20 tasks/sec — workers will self-throttle against Congress.gov
logger.info(f"backfill_all_bill_actions: queued {queued} bills")
return {"queued": queued}
finally:
db.close()
def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool: def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
"""Update bill fields if anything has changed. Returns True if updated.""" """Update bill fields if anything has changed. Returns True if updated."""
changed = False changed = False

View File

@@ -26,6 +26,81 @@ def _parse_pub_at(raw: str | None) -> datetime | None:
return None return None
@celery_app.task(bind=True, max_retries=2, name="app.workers.member_interest.sync_member_interest")
def sync_member_interest(self, bioguide_id: str):
"""
Fetch news and score a member in a single API pass.
Called on first profile view — avoids the 2x NewsAPI + GNews calls that
result from queuing fetch_member_news and calculate_member_trend_score separately.
"""
db = get_sync_db()
try:
member = db.get(Member, bioguide_id)
if not member or not member.first_name or not member.last_name:
return {"status": "skipped"}
query = news_service.build_member_query(
first_name=member.first_name,
last_name=member.last_name,
chamber=member.chamber,
)
# Single fetch — results reused for both article storage and scoring
newsapi_articles = news_service.fetch_newsapi_articles(query, days=30)
gnews_articles = news_service.fetch_gnews_articles(query, days=30)
all_articles = newsapi_articles + gnews_articles
saved = 0
for article in all_articles:
url = article.get("url")
if not url:
continue
existing = (
db.query(MemberNewsArticle)
.filter_by(member_id=bioguide_id, url=url)
.first()
)
if existing:
continue
db.add(MemberNewsArticle(
member_id=bioguide_id,
source=article.get("source", "")[:200],
headline=article.get("headline", ""),
url=url,
published_at=_parse_pub_at(article.get("published_at")),
relevance_score=1.0,
))
saved += 1
# Score using counts already in hand — no second API round-trip
today = date.today()
if not db.query(MemberTrendScore).filter_by(member_id=bioguide_id, score_date=today).first():
keywords = trends_service.keywords_for_member(member.first_name, member.last_name)
gtrends_score = trends_service.get_trends_score(keywords)
composite = calculate_composite_score(
len(newsapi_articles), len(gnews_articles), gtrends_score
)
db.add(MemberTrendScore(
member_id=bioguide_id,
score_date=today,
newsapi_count=len(newsapi_articles),
gnews_count=len(gnews_articles),
gtrends_score=gtrends_score,
composite_score=composite,
))
db.commit()
logger.info(f"Synced member interest for {bioguide_id}: {saved} articles saved")
return {"status": "ok", "saved": saved}
except Exception as exc:
db.rollback()
logger.error(f"Member interest sync failed for {bioguide_id}: {exc}")
raise self.retry(exc=exc, countdown=300)
finally:
db.close()
@celery_app.task(bind=True, max_retries=2, name="app.workers.member_interest.fetch_member_news") @celery_app.task(bind=True, max_retries=2, name="app.workers.member_interest.fetch_member_news")
def fetch_member_news(self, bioguide_id: str): def fetch_member_news(self, bioguide_id: str):
"""Fetch and store recent news articles for a specific member.""" """Fetch and store recent news articles for a specific member."""

View File

@@ -0,0 +1,115 @@
"""
Notification dispatcher — sends pending notification events via ntfy.
RSS is pull-based so no dispatch is needed for it; events are simply
marked dispatched once ntfy is sent (or immediately if the user has no
ntfy configured but has an RSS token, so the feed can clean up old items).
Runs every 5 minutes on Celery Beat.
"""
import logging
from datetime import datetime, timezone
import requests
from app.database import get_sync_db
from app.models.notification import NotificationEvent
from app.models.user import User
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
NTFY_TIMEOUT = 10
_EVENT_TITLES = {
"new_document": "New Bill Text Published",
"new_amendment": "Amendment Filed",
"bill_updated": "Bill Updated",
}
@celery_app.task(bind=True, name="app.workers.notification_dispatcher.dispatch_notifications")
def dispatch_notifications(self):
"""Fan out pending notification events to ntfy and mark dispatched."""
db = get_sync_db()
try:
pending = (
db.query(NotificationEvent)
.filter(NotificationEvent.dispatched_at.is_(None))
.order_by(NotificationEvent.created_at)
.limit(200)
.all()
)
sent = 0
failed = 0
now = datetime.now(timezone.utc)
for event in pending:
user = db.get(User, event.user_id)
if not user:
event.dispatched_at = now
db.commit()
continue
prefs = user.notification_prefs or {}
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
ntfy_token = prefs.get("ntfy_token", "").strip()
ntfy_username = prefs.get("ntfy_username", "").strip()
ntfy_password = prefs.get("ntfy_password", "").strip()
ntfy_enabled = prefs.get("ntfy_enabled", False)
rss_enabled = prefs.get("rss_enabled", False)
if ntfy_enabled and ntfy_url:
try:
_send_ntfy(event, ntfy_url, ntfy_auth_method, ntfy_token, ntfy_username, ntfy_password)
sent += 1
except Exception as e:
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
failed += 1
# Mark dispatched once handled by at least one enabled channel.
# RSS is pull-based — no action needed beyond creating the event record.
if (ntfy_enabled and ntfy_url) or rss_enabled:
event.dispatched_at = now
db.commit()
logger.info(f"dispatch_notifications: {sent} sent, {failed} failed, {len(pending)} pending")
return {"sent": sent, "failed": failed, "total": len(pending)}
finally:
db.close()
def _send_ntfy(
event: NotificationEvent,
topic_url: str,
auth_method: str = "none",
token: str = "",
username: str = "",
password: str = "",
) -> None:
import base64
payload = event.payload or {}
bill_label = payload.get("bill_label", event.bill_id.upper())
bill_title = payload.get("bill_title", "")
message = f"{bill_label}: {bill_title}"
if payload.get("brief_summary"):
message += f"\n\n{payload['brief_summary'][:280]}"
headers = {
"Title": _EVENT_TITLES.get(event.event_type, "Bill Update"),
"Priority": "default",
"Tags": "scroll",
}
if payload.get("bill_url"):
headers["Click"] = payload["bill_url"]
if auth_method == "token" and token:
headers["Authorization"] = f"Bearer {token}"
elif auth_method == "basic" and username:
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
headers["Authorization"] = f"Basic {creds}"
resp = requests.post(topic_url, data=message.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT)
resp.raise_for_status()

View File

@@ -0,0 +1,282 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Bell, Rss, CheckCircle, Copy, RefreshCw } from "lucide-react";
import { notificationsAPI } from "@/lib/api";
const AUTH_METHODS = [
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" },
{ value: "token", label: "Access token", hint: "ntfy token (tk_...)" },
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
];
export default function NotificationsPage() {
const { data: settings, refetch } = useQuery({
queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(),
});
const update = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data),
onSuccess: () => refetch(),
});
const resetRss = useMutation({
mutationFn: () => notificationsAPI.resetRssToken(),
onSuccess: () => refetch(),
});
// ntfy form state
const [topicUrl, setTopicUrl] = useState("");
const [authMethod, setAuthMethod] = useState("none");
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [ntfySaved, setNtfySaved] = useState(false);
// RSS state
const [rssSaved, setRssSaved] = useState(false);
const [copied, setCopied] = useState(false);
// Populate from loaded settings
useEffect(() => {
if (!settings) return;
setTopicUrl(settings.ntfy_topic_url ?? "");
setAuthMethod(settings.ntfy_auth_method ?? "none");
setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? "");
}, [settings]);
const saveNtfy = (enabled: boolean) => {
update.mutate(
{
ntfy_topic_url: topicUrl,
ntfy_auth_method: authMethod,
ntfy_token: authMethod === "token" ? token : "",
ntfy_username: authMethod === "basic" ? username : "",
ntfy_password: authMethod === "basic" ? password : "",
ntfy_enabled: enabled,
},
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
);
};
const toggleRss = (enabled: boolean) => {
update.mutate(
{ rss_enabled: enabled },
{ onSuccess: () => { setRssSaved(true); setTimeout(() => setRssSaved(false), 2000); } }
);
};
const rssUrl = settings?.rss_token
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
: null;
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bell className="w-5 h-5" /> Notifications
</h1>
<p className="text-muted-foreground text-sm mt-1">
Get alerted when bills you follow are updated, new text is published, or amendments are filed.
</p>
</div>
{/* ntfy */}
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
</h2>
<p className="text-xs text-muted-foreground mt-1">
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> a free, open-source push notification service.
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
</p>
</div>
{settings?.ntfy_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
<CheckCircle className="w-3.5 h-3.5" /> Active
</span>
)}
</div>
{/* Topic URL */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Topic URL</label>
<p className="text-xs text-muted-foreground">
The full URL to your ntfy topic, e.g.{" "}
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
</p>
<input
type="url"
placeholder="https://ntfy.sh/your-topic"
value={topicUrl}
onChange={(e) => setTopicUrl(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{/* Auth method */}
<div className="space-y-2">
<label className="text-sm font-medium">Authentication</label>
<div className="space-y-2">
{AUTH_METHODS.map(({ value, label, hint }) => (
<label key={value} className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="ntfy-auth"
value={value}
checked={authMethod === value}
onChange={() => setAuthMethod(value)}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
</label>
))}
</div>
</div>
{/* Token input */}
{authMethod === "token" && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Access Token</label>
<input
type="password"
placeholder="tk_..."
value={token}
onChange={(e) => setToken(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
)}
{/* Basic auth inputs */}
{authMethod === "basic" && (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Username</label>
<input
type="text"
placeholder="your-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password</label>
<input
type="password"
placeholder="your-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-1 border-t border-border">
<button
onClick={() => saveNtfy(true)}
disabled={!topicUrl.trim() || update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{ntfySaved ? "Saved!" : "Save & Enable"}
</button>
{settings?.ntfy_enabled && (
<button
onClick={() => saveNtfy(false)}
disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable
</button>
)}
</div>
</section>
{/* RSS */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Rss className="w-4 h-4" /> RSS Feed
</h2>
<p className="text-xs text-muted-foreground mt-1">
A private, tokenized RSS feed of your bill alerts subscribe in any RSS reader.
Independent of ntfy; enable either or both.
</p>
</div>
{settings?.rss_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
<CheckCircle className="w-3.5 h-3.5" /> Active
</span>
)}
</div>
{rssUrl && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code>
<button
onClick={() => {
navigator.clipboard.writeText(rssUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors"
title="Copy RSS URL"
>
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
</div>
)}
<div className="flex items-center gap-3 pt-1 border-t border-border">
{!settings?.rss_enabled ? (
<button
onClick={() => toggleRss(true)}
disabled={update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
{rssSaved ? "Enabled!" : "Enable RSS"}
</button>
) : (
<button
onClick={() => toggleRss(false)}
disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable RSS
</button>
)}
{rssUrl && (
<button
onClick={() => resetRss.mutate()}
disabled={resetRss.isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Generate a new URL — old URL will stop working"
>
<RefreshCw className="w-3 h-3" />
Regenerate URL
</button>
)}
</div>
</section>
</div>
);
}

View File

@@ -13,14 +13,11 @@ import {
Trash2, Trash2,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
FileText,
Brain,
BarChart3, BarChart3,
Bell, Bell,
Copy,
Rss,
} from "lucide-react"; } from "lucide-react";
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api"; import Link from "next/link";
import { settingsAPI, adminAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [ const LLM_PROVIDERS = [
@@ -80,27 +77,6 @@ export default function SettingsPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
}); });
const { data: notifSettings, refetch: refetchNotif } = useQuery({
queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(),
});
const updateNotif = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data),
onSuccess: () => refetchNotif(),
});
const resetRss = useMutation({
mutationFn: () => notificationsAPI.resetRssToken(),
onSuccess: () => refetchNotif(),
});
const [ntfyUrl, setNtfyUrl] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [notifSaved, setNotifSaved] = useState(false);
const [copied, setCopied] = useState(false);
// Live model list from provider API // Live model list from provider API
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({ const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
queryKey: ["llm-models", settings?.llm_provider], queryKey: ["llm-models", settings?.llm_provider],
@@ -194,6 +170,21 @@ export default function SettingsPage() {
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p> <p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
</div> </div>
{/* Notifications link */}
<Link
href="/notifications"
className="flex items-center justify-between bg-card border border-border rounded-lg p-4 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-3">
<Bell className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
<div>
<div className="text-sm font-medium">Notification Settings</div>
<div className="text-xs text-muted-foreground">Configure ntfy push alerts and RSS feed per user</div>
</div>
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground"></span>
</Link>
{/* Analysis Status */} {/* Analysis Status */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4"> <section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2"> <h2 className="font-semibold flex items-center gap-2">
@@ -488,113 +479,6 @@ export default function SettingsPage() {
</div> </div>
</section> </section>
{/* Notifications */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Notifications
</h2>
{/* ntfy */}
<div className="space-y-3">
<div>
<label className="text-sm font-medium">ntfy Topic URL</label>
<p className="text-xs text-muted-foreground mb-1.5">
Your ntfy topic use <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy.sh</a> (public) or your self-hosted server.
e.g. <code className="bg-muted px-1 rounded text-xs">https://ntfy.sh/your-topic</code>
</p>
<input
type="url"
placeholder="https://ntfy.sh/your-topic"
defaultValue={notifSettings?.ntfy_topic_url ?? ""}
onChange={(e) => setNtfyUrl(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm font-medium">ntfy Auth Token <span className="text-muted-foreground font-normal">(optional)</span></label>
<p className="text-xs text-muted-foreground mb-1.5">Required only for private/self-hosted topics with access control.</p>
<input
type="password"
placeholder="tk_..."
defaultValue={notifSettings?.ntfy_token ?? ""}
onChange={(e) => setNtfyToken(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={() => {
updateNotif.mutate({
ntfy_topic_url: ntfyUrl || notifSettings?.ntfy_topic_url || "",
ntfy_token: ntfyToken || notifSettings?.ntfy_token || "",
ntfy_enabled: true,
}, {
onSuccess: () => { setNotifSaved(true); setTimeout(() => setNotifSaved(false), 2000); }
});
}}
disabled={updateNotif.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{notifSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{notifSaved ? "Saved!" : "Save & Enable"}
</button>
{notifSettings?.ntfy_enabled && (
<button
onClick={() => updateNotif.mutate({ ntfy_enabled: false })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable
</button>
)}
{notifSettings?.ntfy_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle className="w-3 h-3" /> ntfy active
</span>
)}
</div>
</div>
{/* RSS */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2">
<Rss className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">RSS Feed</span>
</div>
{notifSettings?.rss_token ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-1.5 rounded truncate">
{`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`
);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="shrink-0 p-1.5 rounded hover:bg-accent transition-colors"
title="Copy RSS URL"
>
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
<button
onClick={() => resetRss.mutate()}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Regenerate URL (invalidates old link)
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Save your ntfy settings above to generate your personal RSS feed URL.
</p>
)}
</div>
</section>
{/* API Health */} {/* API Health */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4"> <section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -683,6 +567,15 @@ export default function SettingsPage() {
fn: adminAPI.triggerFetchActions, fn: adminAPI.triggerFetchActions,
status: "on-demand", status: "on-demand",
}, },
{
key: "backfill-actions",
name: "Backfill All Action Histories",
description: "One-time catch-up: fetch action histories for all bills that were imported before this feature existed. Run once to populate timelines across your full bill archive.",
fn: adminAPI.backfillAllActions,
status: stats ? (stats.bills_missing_actions > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_actions,
countLabel: "bills missing action history",
},
{ {
key: "sponsors", key: "sponsors",
name: "Backfill Sponsors", name: "Backfill Sponsors",

View File

@@ -8,6 +8,7 @@ import {
Users, Users,
Tags, Tags,
Heart, Heart,
Bell,
Settings, Settings,
Landmark, Landmark,
LogOut, LogOut,
@@ -24,6 +25,7 @@ const NAV = [
{ href: "/members", label: "Members", icon: Users, adminOnly: false }, { href: "/members", label: "Members", icon: Users, adminOnly: false },
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false }, { href: "/topics", label: "Topics", icon: Tags, adminOnly: false },
{ href: "/following", label: "Following", icon: Heart, adminOnly: false }, { href: "/following", label: "Following", icon: Heart, adminOnly: false },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true }, { href: "/settings", label: "Admin", icon: Settings, adminOnly: true },
]; ];

View File

@@ -161,6 +161,7 @@ export interface AnalysisStats {
pending_llm: number; pending_llm: number;
bills_missing_sponsor: number; bills_missing_sponsor: number;
bills_missing_metadata: number; bills_missing_metadata: number;
bills_missing_actions: number;
remaining: number; remaining: number;
} }
@@ -199,6 +200,8 @@ export const adminAPI = {
apiClient.post("/api/admin/backfill-citations").then((r) => r.data), apiClient.post("/api/admin/backfill-citations").then((r) => r.data),
triggerFetchActions: () => triggerFetchActions: () =>
apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data), apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data),
backfillAllActions: () =>
apiClient.post("/api/admin/backfill-all-actions").then((r) => r.data),
backfillMetadata: () => backfillMetadata: () =>
apiClient.post("/api/admin/backfill-metadata").then((r) => r.data), apiClient.post("/api/admin/backfill-metadata").then((r) => r.data),
resumeAnalysis: () => resumeAnalysis: () =>

View File

@@ -157,7 +157,11 @@ export interface SettingsData {
export interface NotificationSettings { export interface NotificationSettings {
ntfy_topic_url: string; ntfy_topic_url: string;
ntfy_auth_method: string; // "none" | "token" | "basic"
ntfy_token: string; ntfy_token: string;
ntfy_username: string;
ntfy_password: string;
ntfy_enabled: boolean; ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null; rss_token: string | null;
} }