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:
465
backend/app/api/notifications.py
Normal file
465
backend/app/api/notifications.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
Notifications API — user notification settings and per-user RSS feed.
|
||||
"""
|
||||
import base64
|
||||
import secrets
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings as app_settings
|
||||
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||
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 (
|
||||
FollowModeTestRequest,
|
||||
NotificationEventSchema,
|
||||
NotificationSettingsResponse,
|
||||
NotificationSettingsUpdate,
|
||||
NotificationTestResult,
|
||||
NtfyTestRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"new_document": "New Bill Text",
|
||||
"new_amendment": "Amendment Filed",
|
||||
"bill_updated": "Bill Updated",
|
||||
"weekly_digest": "Weekly Digest",
|
||||
}
|
||||
|
||||
|
||||
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_set=bool(decrypt_secret(prefs.get("ntfy_password", ""))),
|
||||
ntfy_enabled=prefs.get("ntfy_enabled", False),
|
||||
rss_enabled=prefs.get("rss_enabled", False),
|
||||
rss_token=rss_token,
|
||||
email_enabled=prefs.get("email_enabled", False),
|
||||
email_address=prefs.get("email_address", ""),
|
||||
digest_enabled=prefs.get("digest_enabled", False),
|
||||
digest_frequency=prefs.get("digest_frequency", "daily"),
|
||||
quiet_hours_start=prefs.get("quiet_hours_start"),
|
||||
quiet_hours_end=prefs.get("quiet_hours_end"),
|
||||
timezone=prefs.get("timezone"),
|
||||
alert_filters=prefs.get("alert_filters"),
|
||||
)
|
||||
|
||||
|
||||
@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"] = encrypt_secret(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
|
||||
if body.email_enabled is not None:
|
||||
prefs["email_enabled"] = body.email_enabled
|
||||
if body.email_address is not None:
|
||||
prefs["email_address"] = body.email_address.strip()
|
||||
if body.digest_enabled is not None:
|
||||
prefs["digest_enabled"] = body.digest_enabled
|
||||
if body.digest_frequency is not None:
|
||||
prefs["digest_frequency"] = body.digest_frequency
|
||||
if body.quiet_hours_start is not None:
|
||||
prefs["quiet_hours_start"] = body.quiet_hours_start
|
||||
if body.quiet_hours_end is not None:
|
||||
prefs["quiet_hours_end"] = body.quiet_hours_end
|
||||
if body.timezone is not None:
|
||||
prefs["timezone"] = body.timezone
|
||||
if body.alert_filters is not None:
|
||||
prefs["alert_filters"] = body.alert_filters
|
||||
# Allow clearing quiet hours by passing -1
|
||||
if body.quiet_hours_start == -1:
|
||||
prefs.pop("quiet_hours_start", None)
|
||||
prefs.pop("quiet_hours_end", None)
|
||||
prefs.pop("timezone", None)
|
||||
|
||||
user.notification_prefs = prefs
|
||||
|
||||
if not user.rss_token:
|
||||
user.rss_token = secrets.token_urlsafe(32)
|
||||
# Generate unsubscribe token the first time an email address is saved
|
||||
if prefs.get("email_address") and not user.email_unsubscribe_token:
|
||||
user.email_unsubscribe_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.post("/test/ntfy", response_model=NotificationTestResult)
|
||||
async def test_ntfy(
|
||||
body: NtfyTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a test push notification to verify ntfy settings."""
|
||||
url = body.ntfy_topic_url.strip()
|
||||
if not url:
|
||||
return NotificationTestResult(status="error", detail="Topic URL is required")
|
||||
|
||||
base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/")
|
||||
headers: dict[str, str] = {
|
||||
"Title": "PocketVeto: Test Notification",
|
||||
"Priority": "default",
|
||||
"Tags": "white_check_mark",
|
||||
"Click": f"{base_url}/notifications",
|
||||
}
|
||||
if body.ntfy_auth_method == "token" and body.ntfy_token.strip():
|
||||
headers["Authorization"] = f"Bearer {body.ntfy_token.strip()}"
|
||||
elif body.ntfy_auth_method == "basic" and body.ntfy_username.strip():
|
||||
creds = base64.b64encode(
|
||||
f"{body.ntfy_username.strip()}:{body.ntfy_password}".encode()
|
||||
).decode()
|
||||
headers["Authorization"] = f"Basic {creds}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
content=(
|
||||
"Your PocketVeto notification settings are working correctly. "
|
||||
"Real alerts will link directly to the relevant bill page."
|
||||
).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return NotificationTestResult(status="ok", detail=f"Test notification sent (HTTP {resp.status_code})")
|
||||
except httpx.HTTPStatusError as e:
|
||||
return NotificationTestResult(status="error", detail=f"HTTP {e.response.status_code}: {e.response.text[:200]}")
|
||||
except httpx.RequestError as e:
|
||||
return NotificationTestResult(status="error", detail=f"Connection error: {e}")
|
||||
|
||||
|
||||
@router.post("/test/email", response_model=NotificationTestResult)
|
||||
async def test_email(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a test email to the user's configured email address."""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
user = await db.get(User, current_user.id)
|
||||
prefs = user.notification_prefs or {}
|
||||
email_addr = prefs.get("email_address", "").strip()
|
||||
if not email_addr:
|
||||
return NotificationTestResult(status="error", detail="No email address saved. Save your address first.")
|
||||
|
||||
if not app_settings.SMTP_HOST:
|
||||
return NotificationTestResult(status="error", detail="SMTP not configured on this server. Set SMTP_HOST in .env")
|
||||
|
||||
try:
|
||||
from_addr = app_settings.SMTP_FROM or app_settings.SMTP_USER
|
||||
base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/")
|
||||
body = (
|
||||
"This is a test email from PocketVeto.\n\n"
|
||||
"Your email notification settings are working correctly. "
|
||||
"Real alerts will include bill titles, summaries, and direct links.\n\n"
|
||||
f"Visit your notifications page: {base_url}/notifications"
|
||||
)
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = "PocketVeto: Test Email Notification"
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = email_addr
|
||||
|
||||
use_ssl = app_settings.SMTP_PORT == 465
|
||||
if use_ssl:
|
||||
ctx = smtplib.SMTP_SSL(app_settings.SMTP_HOST, app_settings.SMTP_PORT, timeout=10)
|
||||
else:
|
||||
ctx = smtplib.SMTP(app_settings.SMTP_HOST, app_settings.SMTP_PORT, timeout=10)
|
||||
with ctx as s:
|
||||
if not use_ssl and app_settings.SMTP_STARTTLS:
|
||||
s.starttls()
|
||||
if app_settings.SMTP_USER:
|
||||
s.login(app_settings.SMTP_USER, app_settings.SMTP_PASSWORD)
|
||||
s.sendmail(from_addr, [email_addr], msg.as_string())
|
||||
|
||||
return NotificationTestResult(status="ok", detail=f"Test email sent to {email_addr}")
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
return NotificationTestResult(status="error", detail="SMTP authentication failed — check SMTP_USER and SMTP_PASSWORD in .env")
|
||||
except smtplib.SMTPConnectError:
|
||||
return NotificationTestResult(status="error", detail=f"Could not connect to {app_settings.SMTP_HOST}:{app_settings.SMTP_PORT}")
|
||||
except Exception as e:
|
||||
return NotificationTestResult(status="error", detail=str(e))
|
||||
|
||||
|
||||
@router.post("/test/rss", response_model=NotificationTestResult)
|
||||
async def test_rss(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Verify the user's RSS feed is reachable and return its event count."""
|
||||
user = await db.get(User, current_user.id)
|
||||
if not user.rss_token:
|
||||
return NotificationTestResult(status="error", detail="RSS token not generated — save settings first")
|
||||
|
||||
count_result = await db.execute(
|
||||
select(NotificationEvent).where(NotificationEvent.user_id == user.id)
|
||||
)
|
||||
event_count = len(count_result.scalars().all())
|
||||
|
||||
return NotificationTestResult(
|
||||
status="ok",
|
||||
detail=f"RSS feed is active with {event_count} event{'s' if event_count != 1 else ''}. Subscribe to the URL shown above.",
|
||||
event_count=event_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[NotificationEventSchema])
|
||||
async def get_notification_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the 50 most recent notification events for the current user."""
|
||||
result = await db.execute(
|
||||
select(NotificationEvent)
|
||||
.where(NotificationEvent.user_id == current_user.id)
|
||||
.order_by(NotificationEvent.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/test/follow-mode", response_model=NotificationTestResult)
|
||||
async def test_follow_mode(
|
||||
body: FollowModeTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Simulate dispatcher behaviour for a given follow mode + event type."""
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.follow import Follow
|
||||
|
||||
VALID_MODES = {"pocket_veto", "pocket_boost"}
|
||||
VALID_EVENTS = {"new_document", "new_amendment", "bill_updated"}
|
||||
if body.mode not in VALID_MODES:
|
||||
return NotificationTestResult(status="error", detail=f"mode must be one of {VALID_MODES}")
|
||||
if body.event_type not in VALID_EVENTS:
|
||||
return NotificationTestResult(status="error", detail=f"event_type must be one of {VALID_EVENTS}")
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(Follow).where(
|
||||
Follow.user_id == current_user.id,
|
||||
Follow.follow_type == "bill",
|
||||
).limit(1)
|
||||
)
|
||||
follow = result.scalar_one_or_none()
|
||||
if not follow:
|
||||
return NotificationTestResult(
|
||||
status="error",
|
||||
detail="No bill follows found — follow at least one bill first",
|
||||
)
|
||||
|
||||
# Pocket Veto suppression: brief events are silently dropped
|
||||
if body.mode == "pocket_veto" and body.event_type in ("new_document", "new_amendment"):
|
||||
return NotificationTestResult(
|
||||
status="ok",
|
||||
detail=(
|
||||
f"✓ Suppressed — Pocket Veto correctly blocked a '{body.event_type}' event. "
|
||||
"No ntfy was sent (this is the expected behaviour)."
|
||||
),
|
||||
)
|
||||
|
||||
# Everything else would send ntfy — check the user has it configured
|
||||
user = await db.get(User, current_user.id)
|
||||
prefs = user.notification_prefs or {}
|
||||
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
|
||||
ntfy_enabled = prefs.get("ntfy_enabled", False)
|
||||
if not ntfy_enabled or not ntfy_url:
|
||||
return NotificationTestResult(
|
||||
status="error",
|
||||
detail="ntfy not configured or disabled — enable it in Notification Settings first.",
|
||||
)
|
||||
|
||||
bill_url = f"{(app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip('/')}/bills/{follow.follow_value}"
|
||||
event_titles = {
|
||||
"new_document": "New Bill Text",
|
||||
"new_amendment": "Amendment Filed",
|
||||
"bill_updated": "Bill Updated",
|
||||
}
|
||||
mode_label = body.mode.replace("_", " ").title()
|
||||
headers: dict[str, str] = {
|
||||
"Title": f"[{mode_label} Test] {event_titles[body.event_type]}: {follow.follow_value.upper()}",
|
||||
"Priority": "default",
|
||||
"Tags": "test_tube",
|
||||
"Click": bill_url,
|
||||
}
|
||||
if body.mode == "pocket_boost":
|
||||
headers["Actions"] = (
|
||||
f"view, View Bill, {bill_url}; "
|
||||
"view, Find Your Rep, https://www.house.gov/representatives/find-your-representative"
|
||||
)
|
||||
|
||||
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()
|
||||
if auth_method == "token" and ntfy_token:
|
||||
headers["Authorization"] = f"Bearer {ntfy_token}"
|
||||
elif auth_method == "basic" and ntfy_username:
|
||||
creds = base64.b64encode(f"{ntfy_username}:{ntfy_password}".encode()).decode()
|
||||
headers["Authorization"] = f"Basic {creds}"
|
||||
|
||||
message_lines = [
|
||||
f"This is a test of {mode_label} mode for bill {follow.follow_value.upper()}.",
|
||||
f"Event type: {event_titles[body.event_type]}",
|
||||
]
|
||||
if body.mode == "pocket_boost":
|
||||
message_lines.append("Tap the action buttons below to view the bill or find your representative.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
ntfy_url,
|
||||
content="\n".join(message_lines).encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
detail = f"✓ ntfy sent (HTTP {resp.status_code})"
|
||||
if body.mode == "pocket_boost":
|
||||
detail += " — check your phone for 'View Bill' and 'Find Your Rep' action buttons"
|
||||
return NotificationTestResult(status="ok", detail=detail)
|
||||
except httpx.HTTPStatusError as e:
|
||||
return NotificationTestResult(status="error", detail=f"HTTP {e.response.status_code}: {e.response.text[:200]}")
|
||||
except httpx.RequestError as e:
|
||||
return NotificationTestResult(status="error", detail=f"Connection error: {e}")
|
||||
|
||||
|
||||
@router.get("/unsubscribe/{token}", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def email_unsubscribe(token: str, db: AsyncSession = Depends(get_db)):
|
||||
"""One-click email unsubscribe — no login required."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.email_unsubscribe_token == token)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return HTMLResponse(
|
||||
_unsubscribe_page("Invalid or expired link", success=False),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
prefs = dict(user.notification_prefs or {})
|
||||
prefs["email_enabled"] = False
|
||||
user.notification_prefs = prefs
|
||||
await db.commit()
|
||||
|
||||
return HTMLResponse(_unsubscribe_page("You've been unsubscribed from PocketVeto email notifications.", success=True))
|
||||
|
||||
|
||||
def _unsubscribe_page(message: str, success: bool) -> str:
|
||||
color = "#16a34a" if success else "#dc2626"
|
||||
icon = "✓" if success else "✗"
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>PocketVeto — Unsubscribe</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#f9fafb;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}}
|
||||
.card{{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:2.5rem;max-width:420px;width:100%;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.08)}}
|
||||
.icon{{font-size:2.5rem;color:{color};margin-bottom:1rem}}
|
||||
h1{{font-size:1.1rem;font-weight:600;color:#111827;margin:0 0 .5rem}}
|
||||
p{{font-size:.9rem;color:#6b7280;margin:0 0 1.5rem;line-height:1.5}}
|
||||
a{{color:#2563eb;text-decoration:none;font-size:.875rem}}a:hover{{text-decoration:underline}}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<div class="icon">{icon}</div>
|
||||
<h1>Email Notifications</h1>
|
||||
<p>{message}</p>
|
||||
<a href="/">Return to PocketVeto</a>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
@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")
|
||||
Reference in New Issue
Block a user