From 73881b240471af0fab929575ac0e19c818f3dfca Mon Sep 17 00:00:00 2001
From: Jack Levy
Date: Sun, 1 Mar 2026 15:09:13 -0500
Subject: [PATCH] feat(notifications): follow modes, milestone alerts,
notification enhancements
Follow Modes (neutral / pocket_veto / pocket_boost):
- Alembic migration 0013 adds follow_mode column to follows table
- FollowButton rewritten as mode-aware dropdown for bills; simple toggle for members/topics
- PATCH /api/follows/{id}/mode endpoint with validation
- Dispatcher filters pocket_veto follows (suppress new_document/new_amendment events)
- Dispatcher adds ntfy Actions header for pocket_boost follows
Change-driven (milestone) Alerts:
- New notification_utils.py with shared emit helpers and 30-min dedup
- congress_poller emits bill_updated events on milestone action text
- llm_processor replaced with shared emit util (also notifies member/topic followers)
Notification Enhancements:
- ntfy priority levels (high for bill_updated, default for others)
- Quiet hours (UTC): dispatcher holds events outside allowed window
- Digest mode (daily/weekly): send_notification_digest Celery beat task
- Notification history endpoint + Recent Alerts UI section
- Enriched following page (bill titles, member photos/details via sub-components)
- Follow mode test buttons in admin settings panel
Infrastructure:
- nginx: switch upstream blocks to set $variable proxy_pass so Docker DNS
re-resolves upstream IPs after container rebuilds (valid=10s)
- TROUBLESHOOTING.md documenting common Docker/nginx/postgres gotchas
Authored-By: Jack Levy
---
TROUBLESHOOTING.md | 158 ++++++++
.../alembic/versions/0013_add_follow_mode.py | 23 ++
backend/app/api/follows.py | 23 +-
backend/app/api/notifications.py | 138 +++++++
backend/app/models/follow.py | 1 +
backend/app/schemas/schemas.py | 31 ++
backend/app/workers/celery_app.py | 4 +
backend/app/workers/congress_poller.py | 9 +
backend/app/workers/llm_processor.py | 40 +-
.../app/workers/notification_dispatcher.py | 180 ++++++++-
backend/app/workers/notification_utils.py | 137 +++++++
frontend/app/bills/[id]/page.tsx | 2 +-
frontend/app/following/page.tsx | 220 ++++++++---
frontend/app/notifications/page.tsx | 369 +++++++++++++-----
frontend/app/settings/page.tsx | 104 ++++-
frontend/components/shared/BillCard.tsx | 2 +-
frontend/components/shared/FollowButton.tsx | 164 ++++++--
frontend/lib/api.ts | 7 +
frontend/lib/hooks/useFollows.ts | 9 +
frontend/lib/types.ts | 19 +
nginx/nginx.conf | 22 +-
21 files changed, 1412 insertions(+), 250 deletions(-)
create mode 100644 TROUBLESHOOTING.md
create mode 100644 backend/alembic/versions/0013_add_follow_mode.py
create mode 100644 backend/app/workers/notification_utils.py
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 0000000..6f53411
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,158 @@
+# Troubleshooting
+
+Common issues encountered during development and deployment of PocketVeto.
+
+---
+
+## 502 Bad Gateway after rebuilding a container
+
+**Symptom**
+
+All API calls return 502. nginx error log shows:
+
+```
+connect() failed (111: Connection refused) while connecting to upstream,
+upstream: "http://172.18.0.X:8000/api/..."
+```
+
+The IP in the error is the *old* IP of the container before the rebuild.
+
+**Root cause**
+
+When nginx uses `upstream` blocks, it resolves hostnames once at process startup and caches the result for the lifetime of the process. Rebuilding a container (e.g. `docker compose build api && docker compose up -d api`) assigns it a new Docker network IP. nginx still holds the old IP and all connections are refused.
+
+**Immediate fix**
+
+```bash
+docker compose restart nginx
+```
+
+This forces nginx to re-resolve all upstream hostnames from Docker's internal DNS (`127.0.0.11`).
+
+**Permanent fix (already applied)**
+
+Replace `upstream` blocks with `set $variable` in `proxy_pass`. nginx only activates the `resolver` directive when a variable is used — making it re-resolve on each request cycle (every `valid=N` seconds).
+
+```nginx
+resolver 127.0.0.11 valid=10s ipv6=off;
+
+# BAD — resolves once at startup, caches forever
+upstream api {
+ server api:8000;
+}
+location /api/ {
+ proxy_pass http://api;
+}
+
+# GOOD — re-resolves via resolver every 10 s
+location /api/ {
+ set $api http://api:8000;
+ proxy_pass $api;
+}
+```
+
+---
+
+## Wrong service name for docker compose exec
+
+The API service is named `api` in `docker-compose.yml`, not `backend`.
+
+```bash
+# Wrong
+docker compose exec backend alembic upgrade head
+
+# Correct
+docker compose exec api alembic upgrade head
+```
+
+---
+
+## Alembic migration not applied after rebuild
+
+If a new migration file was added after the last image build, the API container won't have it baked in. The container runs `alembic upgrade head` at startup from the built image.
+
+**Fix**: rebuild the API image so the new migration file is included, then restart:
+
+```bash
+docker compose build api && docker compose up -d api
+```
+
+---
+
+## Wrong postgres user
+
+The database superuser is `congress` (set via `POSTGRES_USER` in `.env` / `docker-compose.yml`), not the default `postgres`.
+
+```bash
+# Wrong
+docker compose exec postgres psql -U postgres pocketveto
+
+# Correct
+docker compose exec postgres psql -U congress pocketveto
+```
+
+---
+
+## Frontend changes not showing after editing source files
+
+The frontend runs as a production Next.js build (`NODE_ENV=production`) — there is no hot reload. Code changes require a full image rebuild:
+
+```bash
+docker compose build frontend && docker compose up -d frontend
+```
+
+Static assets are cache-busted automatically by Next.js (content-hashed filenames), so a hard refresh in the browser is not required after the new container starts.
+
+---
+
+## Celery tasks not reflecting code changes
+
+Celery worker and beat processes also run from the built image. After changing any worker code:
+
+```bash
+docker compose build worker beat && docker compose up -d worker beat
+```
+
+---
+
+## Checking logs
+
+```bash
+# All services
+docker compose logs -f
+
+# Single service (last 50 lines)
+docker compose logs --tail=50 api
+docker compose logs --tail=50 nginx
+docker compose logs --tail=50 worker
+
+# Follow in real time
+docker compose logs -f api worker
+```
+
+---
+
+## Inspecting the database
+
+```bash
+docker compose exec postgres psql -U congress pocketveto
+```
+
+Useful queries:
+
+```sql
+-- Recent notification events
+SELECT event_type, bill_id, dispatched_at, created_at
+FROM notification_events
+ORDER BY created_at DESC
+LIMIT 20;
+
+-- Follow modes per user
+SELECT u.email, f.follow_type, f.follow_value, f.follow_mode
+FROM follows f
+JOIN users u ON u.id = f.user_id
+ORDER BY u.email, f.follow_type;
+
+-- Users and their RSS tokens
+SELECT id, email, rss_token IS NOT NULL AS has_rss_token FROM users;
+```
diff --git a/backend/alembic/versions/0013_add_follow_mode.py b/backend/alembic/versions/0013_add_follow_mode.py
new file mode 100644
index 0000000..a8be84e
--- /dev/null
+++ b/backend/alembic/versions/0013_add_follow_mode.py
@@ -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")
diff --git a/backend/app/api/follows.py b/backend/app/api/follows.py
index 4b098e7..1d951d4 100644
--- a/backend/app/api/follows.py
+++ b/backend/app/api/follows.py
@@ -7,11 +7,12 @@ from app.core.dependencies import get_current_user
from app.database import get_db
from app.models import Follow
from app.models.user import User
-from app.schemas.schemas import FollowCreate, FollowSchema
+from app.schemas.schemas import FollowCreate, FollowModeUpdate, FollowSchema
router = APIRouter()
VALID_FOLLOW_TYPES = {"bill", "member", "topic"}
+VALID_MODES = {"neutral", "pocket_veto", "pocket_boost"}
@router.get("", response_model=list[FollowSchema])
@@ -58,6 +59,26 @@ async def add_follow(
return follow
+@router.patch("/{follow_id}/mode", response_model=FollowSchema)
+async def update_follow_mode(
+ follow_id: int,
+ body: FollowModeUpdate,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ if body.follow_mode not in VALID_MODES:
+ raise HTTPException(status_code=400, detail=f"follow_mode must be one of {VALID_MODES}")
+ follow = await db.get(Follow, follow_id)
+ if not follow:
+ raise HTTPException(status_code=404, detail="Follow not found")
+ if follow.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail="Not your follow")
+ follow.follow_mode = body.follow_mode
+ await db.commit()
+ await db.refresh(follow)
+ return follow
+
+
@router.delete("/{follow_id}", status_code=204)
async def remove_follow(
follow_id: int,
diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py
index e7432e2..1277595 100644
--- a/backend/app/api/notifications.py
+++ b/backend/app/api/notifications.py
@@ -17,6 +17,8 @@ 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,
@@ -42,6 +44,10 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
ntfy_enabled=prefs.get("ntfy_enabled", False),
rss_enabled=prefs.get("rss_enabled", False),
rss_token=rss_token,
+ 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"),
)
@@ -82,6 +88,18 @@ async def update_notification_settings(
prefs["ntfy_enabled"] = body.ntfy_enabled
if body.rss_enabled is not None:
prefs["rss_enabled"] = body.rss_enabled
+ 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
+ # 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)
user.notification_prefs = prefs
@@ -171,6 +189,126 @@ async def test_rss(
)
+@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("/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."""
diff --git a/backend/app/models/follow.py b/backend/app/models/follow.py
index aa63d75..a6e0106 100644
--- a/backend/app/models/follow.py
+++ b/backend/app/models/follow.py
@@ -12,6 +12,7 @@ class Follow(Base):
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
follow_type = Column(String(20), nullable=False) # bill | member | topic
follow_value = Column(String, nullable=False) # bill_id | bioguide_id | tag string
+ follow_mode = Column(String(20), nullable=False, default="neutral") # neutral | pocket_veto | pocket_boost
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="follows")
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index cbef498..74c1081 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -15,6 +15,12 @@ class NotificationSettingsResponse(BaseModel):
ntfy_enabled: bool = False
rss_enabled: bool = False
rss_token: Optional[str] = None
+ # Digest
+ digest_enabled: bool = False
+ digest_frequency: str = "daily" # daily | weekly
+ # Quiet hours (UTC hour integers 0-23, None = disabled)
+ quiet_hours_start: Optional[int] = None
+ quiet_hours_end: Optional[int] = None
model_config = {"from_attributes": True}
@@ -27,6 +33,21 @@ class NotificationSettingsUpdate(BaseModel):
ntfy_password: Optional[str] = None
ntfy_enabled: Optional[bool] = None
rss_enabled: Optional[bool] = None
+ digest_enabled: Optional[bool] = None
+ digest_frequency: Optional[str] = None
+ quiet_hours_start: Optional[int] = None
+ quiet_hours_end: Optional[int] = None
+
+
+class NotificationEventSchema(BaseModel):
+ id: int
+ bill_id: str
+ event_type: str
+ payload: Optional[Any] = None
+ dispatched_at: Optional[datetime] = None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
class NtfyTestRequest(BaseModel):
@@ -37,6 +58,11 @@ class NtfyTestRequest(BaseModel):
ntfy_password: str = ""
+class FollowModeTestRequest(BaseModel):
+ mode: str # pocket_veto | pocket_boost
+ event_type: str # new_document | new_amendment | bill_updated
+
+
class NotificationTestResult(BaseModel):
status: str # "ok" | "error"
detail: str
@@ -198,11 +224,16 @@ class FollowSchema(BaseModel):
user_id: int
follow_type: str
follow_value: str
+ follow_mode: str = "neutral"
created_at: datetime
model_config = {"from_attributes": True}
+class FollowModeUpdate(BaseModel):
+ follow_mode: str
+
+
# ── Settings ──────────────────────────────────────────────────────────────────
# ── Auth ──────────────────────────────────────────────────────────────────────
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 62be663..acf108a 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -78,5 +78,9 @@ celery_app.conf.update(
"task": "app.workers.notification_dispatcher.dispatch_notifications",
"schedule": crontab(minute="*/5"), # Every 5 minutes
},
+ "send-notification-digest": {
+ "task": "app.workers.notification_dispatcher.send_notification_digest",
+ "schedule": crontab(hour=8, minute=0), # 8 AM UTC daily
+ },
},
)
diff --git a/backend/app/workers/congress_poller.py b/backend/app/workers/congress_poller.py
index 4ba7a79..b18c68a 100644
--- a/backend/app/workers/congress_poller.py
+++ b/backend/app/workers/congress_poller.py
@@ -335,6 +335,15 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
from app.workers.document_fetcher import fetch_bill_documents
fetch_bill_documents.delay(existing.bill_id)
fetch_bill_actions.delay(existing.bill_id)
+ from app.workers.notification_utils import (
+ emit_bill_notification,
+ emit_member_follow_notifications,
+ is_milestone_action,
+ )
+ if is_milestone_action(parsed.get("latest_action_text", "")):
+ action_text = parsed["latest_action_text"]
+ emit_bill_notification(db, existing, "bill_updated", action_text)
+ emit_member_follow_notifications(db, existing, "bill_updated", action_text)
return changed
diff --git a/backend/app/workers/llm_processor.py b/backend/app/workers/llm_processor.py
index cd40074..66b731c 100644
--- a/backend/app/workers/llm_processor.py
+++ b/backend/app/workers/llm_processor.py
@@ -103,8 +103,16 @@ def process_document_with_llm(self, document_id: int):
logger.info(f"{brief_type.capitalize()} brief {db_brief.id} created for bill {doc.bill_id} using {brief.llm_provider}/{brief.llm_model}")
- # Emit notification events for users who follow this bill
- _emit_notification_events(db, bill, doc.bill_id, brief_type, brief.summary)
+ # Emit notification events for bill followers, sponsor followers, and topic followers
+ from app.workers.notification_utils import (
+ emit_bill_notification,
+ emit_member_follow_notifications,
+ emit_topic_follow_notifications,
+ )
+ event_type = "new_amendment" if brief_type == "amendment" else "new_document"
+ emit_bill_notification(db, bill, event_type, brief.summary)
+ emit_member_follow_notifications(db, bill, event_type, brief.summary)
+ emit_topic_follow_notifications(db, bill, event_type, brief.summary, brief.topic_tags or [])
# Trigger news fetch now that we have topic tags
from app.workers.news_fetcher import fetch_news_for_bill
@@ -120,34 +128,6 @@ def process_document_with_llm(self, document_id: int):
db.close()
-def _emit_notification_events(db, bill, bill_id: str, brief_type: str, summary: str | None) -> None:
- """Create a NotificationEvent row for every user following this bill."""
- from app.models.follow import Follow
- from app.models.notification import NotificationEvent
- from app.config import settings
-
- followers = db.query(Follow).filter_by(follow_type="bill", follow_value=bill_id).all()
- if not followers:
- return
-
- base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
- payload = {
- "bill_title": bill.short_title or bill.title or "",
- "bill_label": f"{bill.bill_type.upper()} {bill.bill_number}",
- "brief_summary": (summary or "")[:300],
- "bill_url": f"{base_url}/bills/{bill_id}",
- }
- event_type = "new_amendment" if brief_type == "amendment" else "new_document"
-
- for follow in followers:
- db.add(NotificationEvent(
- user_id=follow.user_id,
- bill_id=bill_id,
- event_type=event_type,
- payload=payload,
- ))
- db.commit()
-
@celery_app.task(bind=True, name="app.workers.llm_processor.backfill_brief_citations")
def backfill_brief_citations(self):
diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py
index 5fda4bd..41c2c8c 100644
--- a/backend/app/workers/notification_dispatcher.py
+++ b/backend/app/workers/notification_dispatcher.py
@@ -7,12 +7,15 @@ ntfy configured but has an RSS token, so the feed can clean up old items).
Runs every 5 minutes on Celery Beat.
"""
+import base64
import logging
-from datetime import datetime, timezone
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
import requests
from app.database import get_sync_db
+from app.models.follow import Follow
from app.models.notification import NotificationEvent
from app.models.user import User
from app.workers.celery_app import celery_app
@@ -33,6 +36,26 @@ _EVENT_TAGS = {
"bill_updated": "rotating_light",
}
+# Milestone events are more urgent than LLM brief events
+_EVENT_PRIORITY = {
+ "bill_updated": "high",
+ "new_document": "default",
+ "new_amendment": "default",
+}
+
+
+def _in_quiet_hours(prefs: dict, now: datetime) -> bool:
+ """Return True if the current UTC hour falls within the user's quiet window."""
+ start = prefs.get("quiet_hours_start")
+ end = prefs.get("quiet_hours_end")
+ if start is None or end is None:
+ return False
+ h = now.hour
+ if start <= end:
+ return start <= h < end
+ # Wraps midnight (e.g. 22 → 8)
+ return h >= start or h < end
+
@celery_app.task(bind=True, name="app.workers.notification_dispatcher.dispatch_notifications")
def dispatch_notifications(self):
@@ -49,6 +72,7 @@ def dispatch_notifications(self):
sent = 0
failed = 0
+ held = 0
now = datetime.now(timezone.utc)
for event in pending:
@@ -58,6 +82,18 @@ def dispatch_notifications(self):
db.commit()
continue
+ # Look up follow mode for this (user, bill) pair
+ follow = db.query(Follow).filter_by(
+ user_id=event.user_id, follow_type="bill", follow_value=event.bill_id
+ ).first()
+ follow_mode = follow.follow_mode if follow else "neutral"
+
+ # Pocket Veto: only milestone (bill_updated) events; skip LLM brief events
+ if follow_mode == "pocket_veto" and event.event_type in ("new_document", "new_amendment"):
+ 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")
@@ -66,23 +102,97 @@ def dispatch_notifications(self):
ntfy_password = prefs.get("ntfy_password", "").strip()
ntfy_enabled = prefs.get("ntfy_enabled", False)
rss_enabled = prefs.get("rss_enabled", False)
+ digest_enabled = prefs.get("digest_enabled", False)
- if ntfy_enabled and ntfy_url:
+ ntfy_configured = ntfy_enabled and bool(ntfy_url)
+
+ # Hold events when ntfy is configured but delivery should be deferred
+ in_quiet = _in_quiet_hours(prefs, now) if ntfy_configured else False
+ hold = ntfy_configured and (in_quiet or digest_enabled)
+
+ if hold:
+ held += 1
+ continue # Leave undispatched — digest task or next run after quiet hours
+
+ if ntfy_configured:
try:
- _send_ntfy(event, ntfy_url, ntfy_auth_method, ntfy_token, ntfy_username, ntfy_password)
+ _send_ntfy(
+ event, ntfy_url, ntfy_auth_method, ntfy_token,
+ ntfy_username, ntfy_password, follow_mode=follow_mode,
+ )
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()
+ # Mark dispatched: ntfy was attempted, or user has no ntfy (RSS-only or neither)
+ 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)}
+ logger.info(
+ f"dispatch_notifications: {sent} sent, {failed} failed, "
+ f"{held} held (quiet hours/digest), {len(pending)} total pending"
+ )
+ return {"sent": sent, "failed": failed, "held": held, "total": len(pending)}
+ finally:
+ db.close()
+
+
+@celery_app.task(bind=True, name="app.workers.notification_dispatcher.send_notification_digest")
+def send_notification_digest(self):
+ """
+ Send a bundled ntfy digest for users with digest mode enabled.
+ Runs daily; weekly-frequency users only receive on Mondays.
+ """
+ db = get_sync_db()
+ try:
+ now = datetime.now(timezone.utc)
+ users = db.query(User).all()
+ digest_users = [
+ u for u in users
+ if (u.notification_prefs or {}).get("digest_enabled", False)
+ and (u.notification_prefs or {}).get("ntfy_enabled", False)
+ and (u.notification_prefs or {}).get("ntfy_topic_url", "").strip()
+ ]
+
+ sent = 0
+ for user in digest_users:
+ prefs = user.notification_prefs or {}
+ frequency = prefs.get("digest_frequency", "daily")
+
+ # Weekly digests only fire on Mondays (weekday 0)
+ if frequency == "weekly" and now.weekday() != 0:
+ continue
+
+ lookback_hours = 168 if frequency == "weekly" else 24
+ cutoff = now - timedelta(hours=lookback_hours)
+
+ events = (
+ db.query(NotificationEvent)
+ .filter_by(user_id=user.id)
+ .filter(
+ NotificationEvent.dispatched_at.is_(None),
+ NotificationEvent.created_at > cutoff,
+ )
+ .order_by(NotificationEvent.created_at.desc())
+ .all()
+ )
+
+ if not events:
+ continue
+
+ try:
+ ntfy_url = prefs.get("ntfy_topic_url", "").strip()
+ _send_digest_ntfy(events, ntfy_url, prefs)
+ for event in events:
+ event.dispatched_at = now
+ db.commit()
+ sent += 1
+ except Exception as e:
+ logger.warning(f"Digest send failed for user {user.id}: {e}")
+
+ logger.info(f"send_notification_digest: digests sent to {sent} users")
+ return {"sent": sent}
finally:
db.close()
@@ -94,17 +204,15 @@ def _send_ntfy(
token: str = "",
username: str = "",
password: str = "",
+ follow_mode: str = "neutral",
) -> None:
- import base64
payload = event.payload or {}
bill_label = payload.get("bill_label", event.bill_id.upper())
bill_title = payload.get("bill_title", "")
event_label = _EVENT_TITLES.get(event.event_type, "Bill Update")
- # Title line: event type + bill identifier (e.g. "New Bill Text: HR 1234")
title = f"{event_label}: {bill_label}"
- # Body: full bill name, then AI summary if available
lines = [bill_title] if bill_title else []
if payload.get("brief_summary"):
lines.append("")
@@ -113,12 +221,18 @@ def _send_ntfy(
headers = {
"Title": title,
- "Priority": "default",
+ "Priority": _EVENT_PRIORITY.get(event.event_type, "default"),
"Tags": _EVENT_TAGS.get(event.event_type, "bell"),
}
if payload.get("bill_url"):
headers["Click"] = payload["bill_url"]
+ if follow_mode == "pocket_boost":
+ headers["Actions"] = (
+ f"view, View Bill, {payload.get('bill_url', '')}; "
+ "view, Find Your Rep, https://www.house.gov/representatives/find-your-representative"
+ )
+
if auth_method == "token" and token:
headers["Authorization"] = f"Bearer {token}"
elif auth_method == "basic" and username:
@@ -127,3 +241,41 @@ def _send_ntfy(
resp = requests.post(topic_url, data=message.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT)
resp.raise_for_status()
+
+
+def _send_digest_ntfy(events: list, ntfy_url: str, prefs: dict) -> None:
+ 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()
+
+ headers = {
+ "Title": f"PocketVeto Digest — {len(events)} update{'s' if len(events) != 1 else ''}",
+ "Priority": "default",
+ "Tags": "newspaper",
+ }
+
+ 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}"
+
+ # Group by bill, show up to 10
+ by_bill: dict = defaultdict(list)
+ for event in events:
+ by_bill[event.bill_id].append(event)
+
+ lines = []
+ for bill_id, bill_events in list(by_bill.items())[:10]:
+ payload = bill_events[0].payload or {}
+ bill_label = payload.get("bill_label", bill_id.upper())
+ event_labels = list({_EVENT_TITLES.get(e.event_type, "Update") for e in bill_events})
+ lines.append(f"• {bill_label}: {', '.join(event_labels)}")
+
+ if len(by_bill) > 10:
+ lines.append(f" …and {len(by_bill) - 10} more bills")
+
+ message = "\n".join(lines)
+ resp = requests.post(ntfy_url, data=message.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT)
+ resp.raise_for_status()
diff --git a/backend/app/workers/notification_utils.py b/backend/app/workers/notification_utils.py
new file mode 100644
index 0000000..b8c78cb
--- /dev/null
+++ b/backend/app/workers/notification_utils.py
@@ -0,0 +1,137 @@
+"""
+Shared notification utilities — used by llm_processor, congress_poller, etc.
+Centralised here to avoid circular imports.
+"""
+from datetime import datetime, timedelta, timezone
+
+_MILESTONE_KEYWORDS = [
+ "passed", "failed", "agreed to",
+ "signed", "vetoed", "enacted",
+ "presented to the president",
+ "ordered to be reported", "ordered reported",
+ "reported by", "discharged",
+ "placed on", # placed on calendar
+ "cloture", "roll call",
+]
+
+# Events created within this window for the same (user, bill, event_type) are suppressed
+_DEDUP_MINUTES = 30
+
+
+def is_milestone_action(action_text: str) -> bool:
+ t = (action_text or "").lower()
+ return any(kw in t for kw in _MILESTONE_KEYWORDS)
+
+
+def _build_payload(bill, action_summary: str) -> dict:
+ from app.config import settings
+ base_url = (settings.PUBLIC_URL or settings.LOCAL_URL).rstrip("/")
+ return {
+ "bill_title": bill.short_title or bill.title or "",
+ "bill_label": f"{bill.bill_type.upper()} {bill.bill_number}",
+ "brief_summary": (action_summary or "")[:300],
+ "bill_url": f"{base_url}/bills/{bill.bill_id}",
+ }
+
+
+def _is_duplicate(db, user_id: int, bill_id: str, event_type: str) -> bool:
+ """True if an identical event was already created within the dedup window."""
+ from app.models.notification import NotificationEvent
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=_DEDUP_MINUTES)
+ return db.query(NotificationEvent).filter_by(
+ user_id=user_id,
+ bill_id=bill_id,
+ event_type=event_type,
+ ).filter(NotificationEvent.created_at > cutoff).first() is not None
+
+
+def emit_bill_notification(db, bill, event_type: str, action_summary: str) -> int:
+ """Create NotificationEvent rows for every user following this bill. Returns count."""
+ from app.models.follow import Follow
+ from app.models.notification import NotificationEvent
+
+ followers = db.query(Follow).filter_by(follow_type="bill", follow_value=bill.bill_id).all()
+ if not followers:
+ return 0
+
+ payload = _build_payload(bill, action_summary)
+ count = 0
+ for follow in followers:
+ if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
+ continue
+ db.add(NotificationEvent(
+ user_id=follow.user_id,
+ bill_id=bill.bill_id,
+ event_type=event_type,
+ payload=payload,
+ ))
+ count += 1
+ if count:
+ db.commit()
+ return count
+
+
+def emit_member_follow_notifications(db, bill, event_type: str, action_summary: str) -> int:
+ """Notify users following the bill's sponsor (dedup prevents double-alerts for bill+member followers)."""
+ if not bill.sponsor_id:
+ return 0
+
+ from app.models.follow import Follow
+ from app.models.notification import NotificationEvent
+
+ followers = db.query(Follow).filter_by(follow_type="member", follow_value=bill.sponsor_id).all()
+ if not followers:
+ return 0
+
+ payload = _build_payload(bill, action_summary)
+ count = 0
+ for follow in followers:
+ if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
+ continue
+ db.add(NotificationEvent(
+ user_id=follow.user_id,
+ bill_id=bill.bill_id,
+ event_type=event_type,
+ payload=payload,
+ ))
+ count += 1
+ if count:
+ db.commit()
+ return count
+
+
+def emit_topic_follow_notifications(db, bill, event_type: str, action_summary: str, topic_tags: list) -> int:
+ """Notify users following any of the bill's topic tags."""
+ if not topic_tags:
+ return 0
+
+ from app.models.follow import Follow
+ from app.models.notification import NotificationEvent
+
+ # Collect unique followers across all matching tags
+ seen_user_ids: set[int] = set()
+ followers = []
+ for tag in topic_tags:
+ for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
+ if follow.user_id not in seen_user_ids:
+ seen_user_ids.add(follow.user_id)
+ followers.append(follow)
+
+ if not followers:
+ return 0
+
+ payload = _build_payload(bill, action_summary)
+ count = 0
+ for follow in followers:
+ if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
+ continue
+ db.add(NotificationEvent(
+ user_id=follow.user_id,
+ bill_id=bill.bill_id,
+ event_type=event_type,
+ payload=payload,
+ ))
+ count += 1
+ if count:
+ db.commit()
+ return count
diff --git a/frontend/app/bills/[id]/page.tsx b/frontend/app/bills/[id]/page.tsx
index 3346b34..bda01a0 100644
--- a/frontend/app/bills/[id]/page.tsx
+++ b/frontend/app/bills/[id]/page.tsx
@@ -97,7 +97,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)}
-
+
{/* Content grid */}
diff --git a/frontend/app/following/page.tsx b/frontend/app/following/page.tsx
index 827d02c..b6b9dc6 100644
--- a/frontend/app/following/page.tsx
+++ b/frontend/app/following/page.tsx
@@ -1,9 +1,120 @@
"use client";
import Link from "next/link";
-import { Heart, X } from "lucide-react";
+import { Heart, ExternalLink, X } from "lucide-react";
import { useFollows, useRemoveFollow } from "@/lib/hooks/useFollows";
-import { billLabel } from "@/lib/utils";
+import { useBill } from "@/lib/hooks/useBills";
+import { useMember } from "@/lib/hooks/useMembers";
+import { FollowButton } from "@/components/shared/FollowButton";
+import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor } from "@/lib/utils";
+import type { Follow } from "@/lib/types";
+
+// ── Bill row ────────────────────────────────────────────────────────────────
+
+function BillRow({ follow }: { follow: Follow }) {
+ const { data: bill } = useBill(follow.follow_value);
+ const label = bill ? billLabel(bill.bill_type, bill.bill_number) : follow.follow_value;
+
+ return (
+
+
+
+
+ {label}
+
+ {bill?.chamber && (
+
+ {bill.chamber}
+
+ )}
+
+
+ {bill ? (bill.short_title || bill.title || label) :
Loading… }
+
+ {bill?.latest_action_text && (
+
+ {bill.latest_action_date && {formatDate(bill.latest_action_date)} — }
+ {bill.latest_action_text}
+
+ )}
+
+
+
+ );
+}
+
+// ── Member row ───────────────────────────────────────────────────────────────
+
+function MemberRow({ follow, onRemove }: { follow: Follow; onRemove: () => void }) {
+ const { data: member } = useMember(follow.follow_value);
+
+ return (
+
+ {/* Photo */}
+
+ {member?.photo_url ? (
+
+ ) : (
+
+ {member ? member.name[0] : "?"}
+
+ )}
+
+
+ {/* Info */}
+
+
+
+ {member?.name ?? follow.follow_value}
+
+ {member?.party && (
+
+ {member.party}
+
+ )}
+
+ {(member?.chamber || member?.state || member?.district) && (
+
+ {[member.chamber, member.state, member.district ? `District ${member.district}` : null]
+ .filter(Boolean)
+ .join(" · ")}
+
+ )}
+ {member?.official_url && (
+
+ Official site
+
+ )}
+
+
+ {/* Unfollow */}
+
+
+
+
+ );
+}
+
+// ── Page ─────────────────────────────────────────────────────────────────────
export default function FollowingPage() {
const { data: follows = [], isLoading } = useFollows();
@@ -13,33 +124,6 @@ export default function FollowingPage() {
const members = follows.filter((f) => f.follow_type === "member");
const topics = follows.filter((f) => f.follow_type === "topic");
- const Section = ({ title, items, renderValue }: {
- title: string;
- items: typeof follows;
- renderValue: (v: string) => React.ReactNode;
- }) => (
-
-
{title} ({items.length})
- {!items.length ? (
-
Nothing followed yet.
- ) : (
-
- {items.map((f) => (
-
-
{renderValue(f.follow_value)}
-
remove.mutate(f.id)}
- className="text-muted-foreground hover:text-destructive transition-colors p-1"
- >
-
-
-
- ))}
-
- )}
-
- );
-
if (isLoading) return Loading...
;
return (
@@ -51,38 +135,58 @@ export default function FollowingPage() {
Manage what you follow
- {
- const [congress, type, num] = v.split("-");
- return (
-
- {type && num ? billLabel(type, parseInt(num)) : v}
-
- );
- }}
- />
-
- (
-
- {v}
-
+ {/* Bills */}
+
+
Bills ({bills.length})
+ {!bills.length ? (
+
No bills followed yet.
+ ) : (
+
+ {bills.map((f) => )}
+
)}
- />
+
- (
-
- {v.replace("-", " ")}
-
+ {/* Members */}
+
+
Members ({members.length})
+ {!members.length ? (
+
No members followed yet.
+ ) : (
+
+ {members.map((f) => (
+ remove.mutate(f.id)} />
+ ))}
+
)}
- />
+
+
+ {/* Topics */}
+
+
Topics ({topics.length})
+ {!topics.length ? (
+
No topics followed yet.
+ ) : (
+
+ {topics.map((f) => (
+
+
+ {f.follow_value.replace(/-/g, " ")}
+
+ remove.mutate(f.id)}
+ className="text-muted-foreground hover:text-destructive transition-colors p-1"
+ >
+
+
+
+ ))}
+
+ )}
+
);
}
diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx
index 2c783b6..d21f3d1 100644
--- a/frontend/app/notifications/page.tsx
+++ b/frontend/app/notifications/page.tsx
@@ -2,8 +2,13 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
-import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical } from "lucide-react";
+import {
+ Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle,
+ FlaskConical, Clock, Calendar, FileText, AlertTriangle,
+} from "lucide-react";
+import Link from "next/link";
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
+import type { NotificationEvent } from "@/lib/types";
const AUTH_METHODS = [
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" },
@@ -11,12 +16,39 @@ const AUTH_METHODS = [
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
];
+const HOURS = Array.from({ length: 24 }, (_, i) => ({
+ value: i,
+ label: `${i.toString().padStart(2, "0")}:00 UTC`,
+}));
+
+const EVENT_META: Record = {
+ new_document: { label: "New Bill Text", icon: FileText, color: "text-blue-500" },
+ new_amendment: { label: "Amendment Filed", icon: FileText, color: "text-purple-500" },
+ bill_updated: { label: "Bill Updated", icon: AlertTriangle, color: "text-orange-500" },
+};
+
+function timeAgo(iso: string) {
+ const diff = Date.now() - new Date(iso).getTime();
+ const m = Math.floor(diff / 60000);
+ if (m < 1) return "just now";
+ if (m < 60) return `${m}m ago`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}h ago`;
+ return `${Math.floor(h / 24)}d ago`;
+}
+
export default function NotificationsPage() {
const { data: settings, refetch } = useQuery({
queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(),
});
+ const { data: history = [], isLoading: historyLoading } = useQuery({
+ queryKey: ["notification-history"],
+ queryFn: () => notificationsAPI.getHistory(),
+ staleTime: 60 * 1000,
+ });
+
const update = useMutation({
mutationFn: (data: Parameters[0]) =>
notificationsAPI.updateSettings(data),
@@ -46,6 +78,17 @@ export default function NotificationsPage() {
const [rssSaved, setRssSaved] = useState(false);
const [copied, setCopied] = useState(false);
+ // Quiet hours state
+ const [quietEnabled, setQuietEnabled] = useState(false);
+ const [quietStart, setQuietStart] = useState(22);
+ const [quietEnd, setQuietEnd] = useState(8);
+ const [quietSaved, setQuietSaved] = useState(false);
+
+ // Digest state
+ const [digestEnabled, setDigestEnabled] = useState(false);
+ const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
+ const [digestSaved, setDigestSaved] = useState(false);
+
// Populate from loaded settings
useEffect(() => {
if (!settings) return;
@@ -54,6 +97,15 @@ export default function NotificationsPage() {
setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? "");
+ setDigestEnabled(settings.digest_enabled ?? false);
+ setDigestFrequency(settings.digest_frequency ?? "daily");
+ if (settings.quiet_hours_start != null) {
+ setQuietEnabled(true);
+ setQuietStart(settings.quiet_hours_start);
+ setQuietEnd(settings.quiet_hours_end ?? 8);
+ } else {
+ setQuietEnabled(false);
+ }
}, [settings]);
const saveNtfy = (enabled: boolean) => {
@@ -77,6 +129,28 @@ export default function NotificationsPage() {
);
};
+ const saveQuietHours = () => {
+ if (quietEnabled) {
+ update.mutate(
+ { quiet_hours_start: quietStart, quiet_hours_end: quietEnd },
+ { onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
+ );
+ } else {
+ // -1 signals the backend to clear the values
+ update.mutate(
+ { quiet_hours_start: -1 },
+ { onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
+ );
+ }
+ };
+
+ const saveDigest = () => {
+ update.mutate(
+ { digest_enabled: digestEnabled, digest_frequency: digestFrequency },
+ { onSuccess: () => { setDigestSaved(true); setTimeout(() => setDigestSaved(false), 2000); } }
+ );
+ };
+
const testNtfy = async () => {
setNtfyTesting(true);
setNtfyTestResult(null);
@@ -119,6 +193,19 @@ export default function NotificationsPage() {
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
: null;
+ const ResultBadge = ({ result }: { result: NotificationTestResult }) => (
+
+ {result.status === "ok"
+ ?
+ : }
+ {result.detail}
+
+ );
+
return (
@@ -149,7 +236,6 @@ export default function NotificationsPage() {
)}
- {/* Topic URL */}
Topic URL
@@ -165,20 +251,13 @@ export default function NotificationsPage() {
/>
- {/* Auth method */}
Authentication
- {/* Token input */}
{authMethod === "token" && (
Access Token
- 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"
- />
+ 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" />
)}
- {/* Basic auth inputs */}
{authMethod === "basic" && (
)}
- {/* Actions */}
- 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"
- >
+ 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 ? : }
{ntfySaved ? "Saved!" : "Save & Enable"}
-
- {ntfyTesting
- ?
- : }
+
+ {ntfyTesting ? : }
{ntfyTesting ? "Sending…" : "Test"}
{settings?.ntfy_enabled && (
- saveNtfy(false)}
- disabled={update.isPending}
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
- >
+ saveNtfy(false)} disabled={update.isPending}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Disable
)}
- {ntfyTestResult && (
-
- {ntfyTestResult.status === "ok"
- ?
- : }
- {ntfyTestResult.detail}
-
- )}
+ {ntfyTestResult &&
}
+ {/* Quiet Hours */}
+
+
+
+ Quiet Hours
+
+
+ Pause ntfy push notifications during set hours. Events accumulate and fire as a batch when quiet hours end.
+ All times are UTC. RSS is unaffected.
+
+
+
+
+ setQuietEnabled(e.target.checked)}
+ className="rounded" />
+ Enable quiet hours
+
+
+ {quietEnabled && (
+
+
+ From
+ setQuietStart(Number(e.target.value))}
+ className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
+ {HOURS.map(({ value, label }) => {label} )}
+
+
+
+ To
+ setQuietEnd(Number(e.target.value))}
+ className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
+ {HOURS.map(({ value, label }) => {label} )}
+
+
+ {quietStart > quietEnd && (
+
(overnight window)
+ )}
+
+ )}
+
+
+ {quietSaved ? : null}
+ {quietSaved ? "Saved!" : "Save Quiet Hours"}
+
+
+
+ {/* Digest */}
+
+
+
+ Digest Mode
+
+
+ Instead of per-event push notifications, receive a single bundled ntfy summary on a schedule.
+ RSS feed is always real-time regardless of this setting.
+
+
+
+
+ setDigestEnabled(e.target.checked)}
+ className="rounded" />
+ Enable digest mode
+
+
+ {digestEnabled && (
+
+
Frequency
+
+ {(["daily", "weekly"] as const).map((freq) => (
+
+ setDigestFrequency(freq)} />
+ {freq}
+ {freq === "weekly" && (Mondays, 8 AM UTC) }
+ {freq === "daily" && (8 AM UTC) }
+
+ ))}
+
+
+ )}
+
+
+ {digestSaved ? : null}
+ {digestSaved ? "Saved!" : "Save Digest Settings"}
+
+
+
{/* RSS */}
@@ -298,15 +429,8 @@ export default function NotificationsPage() {
Your feed URL
{rssUrl}
- {
- 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"
- >
+ { 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 ? : }
@@ -316,61 +440,90 @@ export default function NotificationsPage() {
{!settings?.rss_enabled ? (
- 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"
- >
+ 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 ? : }
{rssSaved ? "Enabled!" : "Enable RSS"}
) : (
- toggleRss(false)}
- disabled={update.isPending}
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
- >
+ toggleRss(false)} disabled={update.isPending}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Disable RSS
)}
{rssUrl && (
<>
-
- {rssTesting
- ?
- : }
+
+ {rssTesting ? : }
{rssTesting ? "Checking…" : "Test"}
- resetRss.mutate()}
- disabled={resetRss.isPending}
+ 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"
- >
-
- Regenerate URL
+ title="Generate a new URL — old URL will stop working">
+ Regenerate URL
>
)}
- {rssTestResult && (
-
- {rssTestResult.status === "ok"
- ?
- : }
- {rssTestResult.detail}
-
- )}
+ {rssTestResult &&
}
+
+ {/* Notification History */}
+
+
+
+
+ Recent Alerts
+
+
Last 50 notification events for your account.
+
+
+
+ {historyLoading ? (
+ Loading history…
+ ) : history.length === 0 ? (
+
+ No events yet. Follow some bills and check back after the next poll.
+
+ ) : (
+
+ {history.map((event: NotificationEvent) => {
+ const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" };
+ const Icon = meta.icon;
+ const payload = event.payload ?? {};
+ return (
+
+
+
+
+ {meta.label}
+ {payload.bill_label && (
+
+ {payload.bill_label}
+
+ )}
+ {timeAgo(event.created_at)}
+
+ {payload.bill_title && (
+
{payload.bill_title}
+ )}
+ {payload.brief_summary && (
+
{payload.brief_summary}
+ )}
+
+
+ {event.dispatched_at ? "✓" : "⏳"}
+
+
+ );
+ })}
+
+ )}
+
);
}
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx
index 33b3011..f211079 100644
--- a/frontend/app/settings/page.tsx
+++ b/frontend/app/settings/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import React, { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Settings,
@@ -15,9 +15,11 @@ import {
ShieldOff,
BarChart3,
Bell,
+ Shield,
+ Zap,
} from "lucide-react";
import Link from "next/link";
-import { settingsAPI, adminAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
+import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [
@@ -112,6 +114,23 @@ export default function SettingsPage() {
model?: string;
} | null>(null);
const [testing, setTesting] = useState(false);
+
+ const [modeTestResults, setModeTestResults] = useState>({});
+ const [modeTestRunning, setModeTestRunning] = useState>({});
+ const runModeTest = async (key: string, mode: string, event_type: string) => {
+ setModeTestRunning((p) => ({ ...p, [key]: true }));
+ try {
+ const result = await notificationsAPI.testFollowMode(mode, event_type);
+ setModeTestResults((p) => ({ ...p, [key]: result }));
+ } catch (e: unknown) {
+ setModeTestResults((p) => ({
+ ...p,
+ [key]: { status: "error", detail: e instanceof Error ? e.message : String(e) },
+ }));
+ } finally {
+ setModeTestRunning((p) => ({ ...p, [key]: false }));
+ }
+ };
const [taskIds, setTaskIds] = useState>({});
const [taskStatuses, setTaskStatuses] = useState>({});
const [confirmDelete, setConfirmDelete] = useState(null);
@@ -185,6 +204,87 @@ export default function SettingsPage() {
→
+ {/* Follow Mode Notification Testing */}
+
+
+
+ Follow Mode Notifications
+
+
+ Requires at least one bill followed and ntfy configured. Tests use your first followed bill.
+
+
+
+
+ {([
+ {
+ key: "veto-suppress",
+ mode: "pocket_veto",
+ event_type: "new_document",
+ icon: Shield,
+ label: "Pocket Veto — suppress brief",
+ description: "Sends a new_document event. Dispatcher should silently drop it — no ntfy notification.",
+ expectColor: "text-amber-600 dark:text-amber-400",
+ },
+ {
+ key: "veto-deliver",
+ mode: "pocket_veto",
+ event_type: "bill_updated",
+ icon: Shield,
+ label: "Pocket Veto — deliver milestone",
+ description: "Sends a bill_updated (milestone) event. Dispatcher should allow it and send ntfy.",
+ expectColor: "text-amber-600 dark:text-amber-400",
+ },
+ {
+ key: "boost-deliver",
+ mode: "pocket_boost",
+ event_type: "bill_updated",
+ icon: Zap,
+ label: "Pocket Boost — deliver with actions",
+ description: "Sends a bill_updated event. ntfy notification should include 'View Bill' and 'Find Your Rep' action buttons.",
+ expectColor: "text-green-600 dark:text-green-400",
+ },
+ ] as Array<{
+ key: string;
+ mode: string;
+ event_type: string;
+ icon: React.ElementType;
+ label: string;
+ description: string;
+ expectColor: string;
+ }>).map(({ key, mode, event_type, icon: Icon, label, description }) => {
+ const result = modeTestResults[key];
+ const running = modeTestRunning[key];
+ return (
+
+
+
+
{label}
+
{description}
+ {result && (
+
+ {result.status === "ok"
+ ?
+ : }
+
+ {result.detail}
+
+
+ )}
+
+
runModeTest(key, mode, event_type)}
+ disabled={running}
+ className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50"
+ >
+ {running ? : "Run"}
+
+
+ );
+ })}
+
+
+
{/* Analysis Status */}
diff --git a/frontend/components/shared/BillCard.tsx b/frontend/components/shared/BillCard.tsx
index 31b79b2..2bf5fca 100644
--- a/frontend/components/shared/BillCard.tsx
+++ b/frontend/components/shared/BillCard.tsx
@@ -62,7 +62,7 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
-
+
{score !== undefined && score > 0 && (
diff --git a/frontend/components/shared/FollowButton.tsx b/frontend/components/shared/FollowButton.tsx
index f753d0b..56a1ff8 100644
--- a/frontend/components/shared/FollowButton.tsx
+++ b/frontend/components/shared/FollowButton.tsx
@@ -1,44 +1,164 @@
"use client";
-import { Heart } from "lucide-react";
-import { useAddFollow, useIsFollowing, useRemoveFollow } from "@/lib/hooks/useFollows";
+import { useRef, useEffect, useState } from "react";
+import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
+import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { cn } from "@/lib/utils";
+const MODES = {
+ neutral: {
+ label: "Following",
+ icon: Heart,
+ color: "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400",
+ },
+ pocket_veto: {
+ label: "Pocket Veto",
+ icon: Shield,
+ color: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400",
+ },
+ pocket_boost: {
+ label: "Pocket Boost",
+ icon: Zap,
+ color: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400",
+ },
+} as const;
+
+type FollowMode = keyof typeof MODES;
+
interface FollowButtonProps {
type: "bill" | "member" | "topic";
value: string;
label?: string;
+ supportsModes?: boolean;
}
-export function FollowButton({ type, value, label }: FollowButtonProps) {
+export function FollowButton({ type, value, label, supportsModes = false }: FollowButtonProps) {
const existing = useIsFollowing(type, value);
const add = useAddFollow();
const remove = useRemoveFollow();
+ const updateMode = useUpdateFollowMode();
+ const [open, setOpen] = useState(false);
+ const dropdownRef = useRef
(null);
const isFollowing = !!existing;
- const isPending = add.isPending || remove.isPending;
+ const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
+ const isPending = add.isPending || remove.isPending || updateMode.isPending;
- const handleClick = () => {
- if (isFollowing && existing) {
- remove.mutate(existing.id);
- } else {
- add.mutate({ type, value });
- }
+ // Close dropdown on outside click
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [open]);
+
+ // Simple toggle for non-bill follows
+ if (!supportsModes) {
+ const handleClick = () => {
+ if (isFollowing && existing) {
+ remove.mutate(existing.id);
+ } else {
+ add.mutate({ type, value });
+ }
+ };
+ return (
+
+
+ {isFollowing ? "Unfollow" : label || "Follow"}
+
+ );
+ }
+
+ // Mode-aware follow button for bills
+ if (!isFollowing) {
+ return (
+ add.mutate({ type, value })}
+ disabled={isPending}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
+ >
+
+ {label || "Follow"}
+
+ );
+ }
+
+ const { label: modeLabel, icon: ModeIcon, color } = MODES[currentMode];
+ const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
+
+ const switchMode = (mode: FollowMode) => {
+ if (existing) updateMode.mutate({ id: existing.id, mode });
+ setOpen(false);
+ };
+
+ const handleUnfollow = () => {
+ if (existing) remove.mutate(existing.id);
+ setOpen(false);
+ };
+
+ const modeDescriptions: Record = {
+ neutral: "Alert me on all material changes",
+ pocket_veto: "Alert me only if this bill advances toward passage",
+ pocket_boost: "Alert me on all changes + remind me to contact my rep",
};
return (
-
+ setOpen((v) => !v)}
+ disabled={isPending}
+ className={cn(
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
+ color
+ )}
+ >
+
+ {modeLabel}
+
+
+
+ {open && (
+
+ {otherModes.map((mode) => {
+ const { label: optLabel, icon: OptIcon } = MODES[mode];
+ return (
+
switchMode(mode)}
+ title={modeDescriptions[mode]}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex flex-col gap-0.5"
+ >
+
+
+ Switch to {optLabel}
+
+ {modeDescriptions[mode]}
+
+ );
+ })}
+
+
+ Unfollow
+
+
+
)}
- >
-
- {isFollowing ? "Unfollow" : label || "Follow"}
-
+
);
}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 9846f83..cb0a16e 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -9,6 +9,7 @@ import type {
MemberTrendScore,
MemberNewsArticle,
NewsArticle,
+ NotificationEvent,
NotificationSettings,
PaginatedResponse,
SettingsData,
@@ -98,6 +99,8 @@ export const followsAPI = {
apiClient.post
("/api/follows", { follow_type, follow_value }).then((r) => r.data),
remove: (id: number) =>
apiClient.delete(`/api/follows/${id}`),
+ updateMode: (id: number, mode: string) =>
+ apiClient.patch(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
};
// Dashboard
@@ -189,6 +192,10 @@ export const notificationsAPI = {
apiClient.post("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () =>
apiClient.post("/api/notifications/test/rss").then((r) => r.data),
+ testFollowMode: (mode: string, event_type: string) =>
+ apiClient.post("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
+ getHistory: () =>
+ apiClient.get("/api/notifications/history").then((r) => r.data),
};
// Admin
diff --git a/frontend/lib/hooks/useFollows.ts b/frontend/lib/hooks/useFollows.ts
index e99a144..40d4054 100644
--- a/frontend/lib/hooks/useFollows.ts
+++ b/frontend/lib/hooks/useFollows.ts
@@ -30,3 +30,12 @@ export function useIsFollowing(type: string, value: string) {
const { data: follows = [] } = useFollows();
return follows.find((f) => f.follow_type === type && f.follow_value === value);
}
+
+export function useUpdateFollowMode() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, mode }: { id: number; mode: string }) =>
+ followsAPI.updateMode(id, mode),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
+ });
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 1605b3a..617ac9c 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -138,6 +138,7 @@ export interface Follow {
id: number;
follow_type: "bill" | "member" | "topic";
follow_value: string;
+ follow_mode: "neutral" | "pocket_veto" | "pocket_boost";
created_at: string;
}
@@ -164,4 +165,22 @@ export interface NotificationSettings {
ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null;
+ digest_enabled: boolean;
+ digest_frequency: "daily" | "weekly";
+ quiet_hours_start: number | null;
+ quiet_hours_end: number | null;
+}
+
+export interface NotificationEvent {
+ id: number;
+ bill_id: string;
+ event_type: "new_document" | "new_amendment" | "bill_updated";
+ payload: {
+ bill_title?: string;
+ bill_label?: string;
+ brief_summary?: string;
+ bill_url?: string;
+ } | null;
+ dispatched_at: string | null;
+ created_at: string;
}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 0c2a79e..0833a1b 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -8,26 +8,20 @@ http {
sendfile on;
keepalive_timeout 65;
- # Use Docker's internal DNS so upstream IPs re-resolve after container restarts
+ # Use Docker's internal DNS; valid=10s forces re-resolution after container restarts.
+ # Variables in proxy_pass activate this resolver (upstream blocks do not).
resolver 127.0.0.11 valid=10s ipv6=off;
- upstream api {
- server api:8000;
- }
-
- upstream frontend {
- server frontend:3000;
- }
-
server {
listen 80;
server_name _;
client_max_body_size 10M;
- # API
+ # API — variable forces re-resolution via resolver on each request cycle
location /api/ {
- proxy_pass http://api;
+ set $api http://api:8000;
+ proxy_pass $api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -38,14 +32,16 @@ http {
# Next.js static assets (long cache)
location /_next/static/ {
- proxy_pass http://frontend;
+ set $frontend http://frontend:3000;
+ proxy_pass $frontend;
proxy_cache_valid 200 1d;
add_header Cache-Control "public, max-age=86400, immutable";
}
# Everything else → frontend
location / {
- proxy_pass http://frontend;
+ set $frontend http://frontend:3000;
+ proxy_pass $frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;