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.name} + ) : ( +
+ {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)}
- -
- ))} -
- )} -
- ); - 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, " ")} + + +
+ ))} +
+ )} +
); } 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 */}

@@ -165,20 +251,13 @@ export default function NotificationsPage() { />

- {/* Auth method */}
{AUTH_METHODS.map(({ value, label, hint }) => (
- {/* Token input */} {authMethod === "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" && (
- 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" - /> + 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" />
- 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" - /> + 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" />
)} - {/* Actions */}
- - {settings?.ntfy_enabled && ( - )}
- {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. +

+
+ + + + {quietEnabled && ( +
+
+ + +
+
+ + +
+ {quietStart > quietEnd && ( + (overnight window) + )} +
+ )} + + +
+ + {/* 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. +

+
+ + + + {digestEnabled && ( +
+ +
+ {(["daily", "weekly"] as const).map((freq) => ( + + ))} +
+
+ )} + + +
+ {/* RSS */}
@@ -298,15 +429,8 @@ export default function NotificationsPage() {
{rssUrl} -
@@ -316,61 +440,90 @@ export default function NotificationsPage() {
{!settings?.rss_enabled ? ( - ) : ( - )} {rssUrl && ( <> - - )}
- {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} + +
+ )} +
+ +
+ ); + })} +
+
+ {/* 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 ( + + ); + } + + // Mode-aware follow button for bills + if (!isFollowing) { + return ( + + ); + } + + 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 ( - + + {open && ( +
+ {otherModes.map((mode) => { + const { label: optLabel, icon: OptIcon } = MODES[mode]; + return ( + + ); + })} +
+ +
+
)} - > - - {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;