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
This commit is contained in:
Jack Levy
2026-03-01 15:09:13 -05:00
parent 22b205ff39
commit 73881b2404
21 changed files with 1412 additions and 250 deletions

158
TROUBLESHOOTING.md Normal file
View File

@@ -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;
```

View File

@@ -0,0 +1,23 @@
"""Add follow_mode column to follows table
Revision ID: 0013
Revises: 0012
"""
from alembic import op
import sqlalchemy as sa
revision = "0013"
down_revision = "0012"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"follows",
sa.Column("follow_mode", sa.String(20), nullable=False, server_default="neutral"),
)
def downgrade():
op.drop_column("follows", "follow_mode")

View File

@@ -7,11 +7,12 @@ from app.core.dependencies import get_current_user
from app.database import get_db from app.database import get_db
from app.models import Follow from app.models import Follow
from app.models.user import User from app.models.user import User
from app.schemas.schemas import FollowCreate, FollowSchema from app.schemas.schemas import FollowCreate, FollowModeUpdate, FollowSchema
router = APIRouter() router = APIRouter()
VALID_FOLLOW_TYPES = {"bill", "member", "topic"} VALID_FOLLOW_TYPES = {"bill", "member", "topic"}
VALID_MODES = {"neutral", "pocket_veto", "pocket_boost"}
@router.get("", response_model=list[FollowSchema]) @router.get("", response_model=list[FollowSchema])
@@ -58,6 +59,26 @@ async def add_follow(
return 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) @router.delete("/{follow_id}", status_code=204)
async def remove_follow( async def remove_follow(
follow_id: int, follow_id: int,

View File

@@ -17,6 +17,8 @@ from app.database import get_db
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
from app.models.user import User from app.models.user import User
from app.schemas.schemas import ( from app.schemas.schemas import (
FollowModeTestRequest,
NotificationEventSchema,
NotificationSettingsResponse, NotificationSettingsResponse,
NotificationSettingsUpdate, NotificationSettingsUpdate,
NotificationTestResult, NotificationTestResult,
@@ -42,6 +44,10 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
ntfy_enabled=prefs.get("ntfy_enabled", False), ntfy_enabled=prefs.get("ntfy_enabled", False),
rss_enabled=prefs.get("rss_enabled", False), rss_enabled=prefs.get("rss_enabled", False),
rss_token=rss_token, 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 prefs["ntfy_enabled"] = body.ntfy_enabled
if body.rss_enabled is not None: if body.rss_enabled is not None:
prefs["rss_enabled"] = body.rss_enabled 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 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) @router.get("/feed/{rss_token}.xml", include_in_schema=False)
async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)): async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)):
"""Public tokenized RSS feed — no auth required.""" """Public tokenized RSS feed — no auth required."""

View File

@@ -12,6 +12,7 @@ class Follow(Base):
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
follow_type = Column(String(20), nullable=False) # bill | member | topic follow_type = Column(String(20), nullable=False) # bill | member | topic
follow_value = Column(String, nullable=False) # bill_id | bioguide_id | tag string 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="follows") user = relationship("User", back_populates="follows")

View File

@@ -15,6 +15,12 @@ class NotificationSettingsResponse(BaseModel):
ntfy_enabled: bool = False ntfy_enabled: bool = False
rss_enabled: bool = False rss_enabled: bool = False
rss_token: Optional[str] = None 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} model_config = {"from_attributes": True}
@@ -27,6 +33,21 @@ class NotificationSettingsUpdate(BaseModel):
ntfy_password: Optional[str] = None ntfy_password: Optional[str] = None
ntfy_enabled: Optional[bool] = None ntfy_enabled: Optional[bool] = None
rss_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): class NtfyTestRequest(BaseModel):
@@ -37,6 +58,11 @@ class NtfyTestRequest(BaseModel):
ntfy_password: str = "" ntfy_password: str = ""
class FollowModeTestRequest(BaseModel):
mode: str # pocket_veto | pocket_boost
event_type: str # new_document | new_amendment | bill_updated
class NotificationTestResult(BaseModel): class NotificationTestResult(BaseModel):
status: str # "ok" | "error" status: str # "ok" | "error"
detail: str detail: str
@@ -198,11 +224,16 @@ class FollowSchema(BaseModel):
user_id: int user_id: int
follow_type: str follow_type: str
follow_value: str follow_value: str
follow_mode: str = "neutral"
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class FollowModeUpdate(BaseModel):
follow_mode: str
# ── Settings ────────────────────────────────────────────────────────────────── # ── Settings ──────────────────────────────────────────────────────────────────
# ── Auth ────────────────────────────────────────────────────────────────────── # ── Auth ──────────────────────────────────────────────────────────────────────

View File

@@ -78,5 +78,9 @@ celery_app.conf.update(
"task": "app.workers.notification_dispatcher.dispatch_notifications", "task": "app.workers.notification_dispatcher.dispatch_notifications",
"schedule": crontab(minute="*/5"), # Every 5 minutes "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
},
}, },
) )

View File

@@ -335,6 +335,15 @@ def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
from app.workers.document_fetcher import fetch_bill_documents from app.workers.document_fetcher import fetch_bill_documents
fetch_bill_documents.delay(existing.bill_id) fetch_bill_documents.delay(existing.bill_id)
fetch_bill_actions.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 return changed

View File

@@ -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}") 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 for bill followers, sponsor followers, and topic followers
_emit_notification_events(db, bill, doc.bill_id, brief_type, brief.summary) 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 # Trigger news fetch now that we have topic tags
from app.workers.news_fetcher import fetch_news_for_bill 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() 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") @celery_app.task(bind=True, name="app.workers.llm_processor.backfill_brief_citations")
def backfill_brief_citations(self): def backfill_brief_citations(self):

View File

@@ -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. Runs every 5 minutes on Celery Beat.
""" """
import base64
import logging import logging
from datetime import datetime, timezone from collections import defaultdict
from datetime import datetime, timedelta, timezone
import requests import requests
from app.database import get_sync_db from app.database import get_sync_db
from app.models.follow import Follow
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
from app.models.user import User from app.models.user import User
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
@@ -33,6 +36,26 @@ _EVENT_TAGS = {
"bill_updated": "rotating_light", "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") @celery_app.task(bind=True, name="app.workers.notification_dispatcher.dispatch_notifications")
def dispatch_notifications(self): def dispatch_notifications(self):
@@ -49,6 +72,7 @@ def dispatch_notifications(self):
sent = 0 sent = 0
failed = 0 failed = 0
held = 0
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for event in pending: for event in pending:
@@ -58,6 +82,18 @@ def dispatch_notifications(self):
db.commit() db.commit()
continue 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 {} prefs = user.notification_prefs or {}
ntfy_url = prefs.get("ntfy_topic_url", "").strip() ntfy_url = prefs.get("ntfy_topic_url", "").strip()
ntfy_auth_method = prefs.get("ntfy_auth_method", "none") 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_password = prefs.get("ntfy_password", "").strip()
ntfy_enabled = prefs.get("ntfy_enabled", False) ntfy_enabled = prefs.get("ntfy_enabled", False)
rss_enabled = prefs.get("rss_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: 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 sent += 1
except Exception as e: except Exception as e:
logger.warning(f"ntfy dispatch failed for event {event.id}: {e}") logger.warning(f"ntfy dispatch failed for event {event.id}: {e}")
failed += 1 failed += 1
# Mark dispatched once handled by at least one enabled channel. # Mark dispatched: ntfy was attempted, or user has no ntfy (RSS-only or neither)
# 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 event.dispatched_at = now
db.commit() db.commit()
logger.info(f"dispatch_notifications: {sent} sent, {failed} failed, {len(pending)} pending") logger.info(
return {"sent": sent, "failed": failed, "total": len(pending)} 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: finally:
db.close() db.close()
@@ -94,17 +204,15 @@ def _send_ntfy(
token: str = "", token: str = "",
username: str = "", username: str = "",
password: str = "", password: str = "",
follow_mode: str = "neutral",
) -> None: ) -> None:
import base64
payload = event.payload or {} payload = event.payload or {}
bill_label = payload.get("bill_label", event.bill_id.upper()) bill_label = payload.get("bill_label", event.bill_id.upper())
bill_title = payload.get("bill_title", "") bill_title = payload.get("bill_title", "")
event_label = _EVENT_TITLES.get(event.event_type, "Bill Update") 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}" title = f"{event_label}: {bill_label}"
# Body: full bill name, then AI summary if available
lines = [bill_title] if bill_title else [] lines = [bill_title] if bill_title else []
if payload.get("brief_summary"): if payload.get("brief_summary"):
lines.append("") lines.append("")
@@ -113,12 +221,18 @@ def _send_ntfy(
headers = { headers = {
"Title": title, "Title": title,
"Priority": "default", "Priority": _EVENT_PRIORITY.get(event.event_type, "default"),
"Tags": _EVENT_TAGS.get(event.event_type, "bell"), "Tags": _EVENT_TAGS.get(event.event_type, "bell"),
} }
if payload.get("bill_url"): if payload.get("bill_url"):
headers["Click"] = payload["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: if auth_method == "token" and token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
elif auth_method == "basic" and username: 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 = requests.post(topic_url, data=message.encode("utf-8"), headers=headers, timeout=NTFY_TIMEOUT)
resp.raise_for_status() 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()

View File

@@ -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

View File

@@ -97,7 +97,7 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)} )}
</p> </p>
</div> </div>
<FollowButton type="bill" value={bill.bill_id} /> <FollowButton type="bill" value={bill.bill_id} supportsModes />
</div> </div>
{/* Content grid */} {/* Content grid */}

View File

@@ -1,9 +1,120 @@
"use client"; "use client";
import Link from "next/link"; 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 { 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 (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{label}
</span>
{bill?.chamber && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
{bill.chamber}
</span>
)}
</div>
<Link
href={`/bills/${follow.follow_value}`}
className="text-sm font-medium hover:text-primary transition-colors line-clamp-2 leading-snug"
>
{bill ? (bill.short_title || bill.title || label) : <span className="text-muted-foreground">Loading</span>}
</Link>
{bill?.latest_action_text && (
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-1">
{bill.latest_action_date && <span>{formatDate(bill.latest_action_date)} </span>}
{bill.latest_action_text}
</p>
)}
</div>
<FollowButton type="bill" value={follow.follow_value} supportsModes />
</div>
);
}
// ── Member row ───────────────────────────────────────────────────────────────
function MemberRow({ follow, onRemove }: { follow: Follow; onRemove: () => void }) {
const { data: member } = useMember(follow.follow_value);
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-center gap-4">
{/* Photo */}
<div className="shrink-0">
{member?.photo_url ? (
<img
src={member.photo_url}
alt={member.name}
className="w-12 h-12 rounded-full object-cover border border-border"
/>
) : (
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center text-lg font-semibold text-muted-foreground">
{member ? member.name[0] : "?"}
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/members/${follow.follow_value}`}
className="text-sm font-semibold hover:text-primary transition-colors"
>
{member?.name ?? follow.follow_value}
</Link>
{member?.party && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
</div>
{(member?.chamber || member?.state || member?.district) && (
<p className="text-xs text-muted-foreground mt-0.5">
{[member.chamber, member.state, member.district ? `District ${member.district}` : null]
.filter(Boolean)
.join(" · ")}
</p>
)}
{member?.official_url && (
<a
href={member.official_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
>
Official site <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
{/* Unfollow */}
<button
onClick={onRemove}
className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0"
title="Unfollow"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function FollowingPage() { export default function FollowingPage() {
const { data: follows = [], isLoading } = useFollows(); const { data: follows = [], isLoading } = useFollows();
@@ -13,33 +124,6 @@ export default function FollowingPage() {
const members = follows.filter((f) => f.follow_type === "member"); const members = follows.filter((f) => f.follow_type === "member");
const topics = follows.filter((f) => f.follow_type === "topic"); const topics = follows.filter((f) => f.follow_type === "topic");
const Section = ({ title, items, renderValue }: {
title: string;
items: typeof follows;
renderValue: (v: string) => React.ReactNode;
}) => (
<div>
<h2 className="font-semibold mb-3">{title} ({items.length})</h2>
{!items.length ? (
<p className="text-sm text-muted-foreground">Nothing followed yet.</p>
) : (
<div className="space-y-2">
{items.map((f) => (
<div key={f.id} className="bg-card border border-border rounded-lg p-3 flex items-center justify-between">
<div>{renderValue(f.follow_value)}</div>
<button
onClick={() => remove.mutate(f.id)}
className="text-muted-foreground hover:text-destructive transition-colors p-1"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>; if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
return ( return (
@@ -51,38 +135,58 @@ export default function FollowingPage() {
<p className="text-muted-foreground text-sm mt-1">Manage what you follow</p> <p className="text-muted-foreground text-sm mt-1">Manage what you follow</p>
</div> </div>
<Section {/* Bills */}
title="Bills" <div>
items={bills} <h2 className="font-semibold mb-3">Bills ({bills.length})</h2>
renderValue={(v) => { {!bills.length ? (
const [congress, type, num] = v.split("-"); <p className="text-sm text-muted-foreground">No bills followed yet.</p>
return ( ) : (
<Link href={`/bills/${v}`} className="text-sm font-medium hover:text-primary transition-colors"> <div className="space-y-2">
{type && num ? billLabel(type, parseInt(num)) : v} {bills.map((f) => <BillRow key={f.id} follow={f} />)}
</Link> </div>
);
}}
/>
<Section
title="Members"
items={members}
renderValue={(v) => (
<Link href={`/members/${v}`} className="text-sm font-medium hover:text-primary transition-colors">
{v}
</Link>
)} )}
/> </div>
<Section {/* Members */}
title="Topics" <div>
items={topics} <h2 className="font-semibold mb-3">Members ({members.length})</h2>
renderValue={(v) => ( {!members.length ? (
<Link href={`/bills?topic=${v}`} className="text-sm font-medium hover:text-primary transition-colors capitalize"> <p className="text-sm text-muted-foreground">No members followed yet.</p>
{v.replace("-", " ")} ) : (
</Link> <div className="space-y-2">
{members.map((f) => (
<MemberRow key={f.id} follow={f} onRemove={() => remove.mutate(f.id)} />
))}
</div>
)} )}
/> </div>
{/* Topics */}
<div>
<h2 className="font-semibold mb-3">Topics ({topics.length})</h2>
{!topics.length ? (
<p className="text-sm text-muted-foreground">No topics followed yet.</p>
) : (
<div className="space-y-2">
{topics.map((f) => (
<div key={f.id} className="bg-card border border-border rounded-lg p-3 flex items-center justify-between">
<Link
href={`/bills?topic=${f.follow_value}`}
className="text-sm font-medium hover:text-primary transition-colors capitalize"
>
{f.follow_value.replace(/-/g, " ")}
</Link>
<button
onClick={() => remove.mutate(f.id)}
className="text-muted-foreground hover:text-destructive transition-colors p-1"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -2,8 +2,13 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; 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 { notificationsAPI, type NotificationTestResult } from "@/lib/api";
import type { NotificationEvent } from "@/lib/types";
const AUTH_METHODS = [ const AUTH_METHODS = [
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" }, { 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" }, { 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<string, { label: string; icon: typeof Bell; color: string }> = {
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() { export default function NotificationsPage() {
const { data: settings, refetch } = useQuery({ const { data: settings, refetch } = useQuery({
queryKey: ["notification-settings"], queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(), queryFn: () => notificationsAPI.getSettings(),
}); });
const { data: history = [], isLoading: historyLoading } = useQuery({
queryKey: ["notification-history"],
queryFn: () => notificationsAPI.getHistory(),
staleTime: 60 * 1000,
});
const update = useMutation({ const update = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) => mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data), notificationsAPI.updateSettings(data),
@@ -46,6 +78,17 @@ export default function NotificationsPage() {
const [rssSaved, setRssSaved] = useState(false); const [rssSaved, setRssSaved] = useState(false);
const [copied, setCopied] = 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 // Populate from loaded settings
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
@@ -54,6 +97,15 @@ export default function NotificationsPage() {
setToken(settings.ntfy_token ?? ""); setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? ""); setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? ""); 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]); }, [settings]);
const saveNtfy = (enabled: boolean) => { 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 () => { const testNtfy = async () => {
setNtfyTesting(true); setNtfyTesting(true);
setNtfyTestResult(null); setNtfyTestResult(null);
@@ -119,6 +193,19 @@ export default function NotificationsPage() {
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml` ? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
: null; : null;
const ResultBadge = ({ result }: { result: NotificationTestResult }) => (
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
result.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{result.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{result.detail}
</div>
);
return ( return (
<div className="space-y-8 max-w-2xl"> <div className="space-y-8 max-w-2xl">
<div> <div>
@@ -149,7 +236,6 @@ export default function NotificationsPage() {
)} )}
</div> </div>
{/* Topic URL */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Topic URL</label> <label className="text-sm font-medium">Topic URL</label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -165,20 +251,13 @@ export default function NotificationsPage() {
/> />
</div> </div>
{/* Auth method */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Authentication</label> <label className="text-sm font-medium">Authentication</label>
<div className="space-y-2"> <div className="space-y-2">
{AUTH_METHODS.map(({ value, label, hint }) => ( {AUTH_METHODS.map(({ value, label, hint }) => (
<label key={value} className="flex items-start gap-3 cursor-pointer"> <label key={value} className="flex items-start gap-3 cursor-pointer">
<input <input type="radio" name="ntfy-auth" value={value} checked={authMethod === value}
type="radio" onChange={() => setAuthMethod(value)} className="mt-0.5" />
name="ntfy-auth"
value={value}
checked={authMethod === value}
onChange={() => setAuthMethod(value)}
className="mt-0.5"
/>
<div> <div>
<div className="text-sm font-medium">{label}</div> <div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div> <div className="text-xs text-muted-foreground">{hint}</div>
@@ -188,90 +267,142 @@ export default function NotificationsPage() {
</div> </div>
</div> </div>
{/* Token input */}
{authMethod === "token" && ( {authMethod === "token" && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Access Token</label> <label className="text-sm font-medium">Access Token</label>
<input <input type="password" placeholder="tk_..." value={token}
type="password"
placeholder="tk_..."
value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
/>
</div> </div>
)} )}
{/* Basic auth inputs */}
{authMethod === "basic" && ( {authMethod === "basic" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Username</label> <label className="text-sm font-medium">Username</label>
<input <input type="text" placeholder="your-username" value={username}
type="text"
placeholder="your-username"
value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Password</label> <label className="text-sm font-medium">Password</label>
<input <input type="password" placeholder="your-password" value={password}
type="password"
placeholder="your-password"
value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
/>
</div> </div>
</div> </div>
)} )}
{/* Actions */}
<div className="space-y-3 pt-1 border-t border-border"> <div className="space-y-3 pt-1 border-t border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button onClick={() => saveNtfy(true)} disabled={!topicUrl.trim() || update.isPending}
onClick={() => saveNtfy(true)} 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">
disabled={!topicUrl.trim() || update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />} {ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{ntfySaved ? "Saved!" : "Save & Enable"} {ntfySaved ? "Saved!" : "Save & Enable"}
</button> </button>
<button <button onClick={testNtfy} disabled={!topicUrl.trim() || ntfyTesting}
onClick={testNtfy} className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
disabled={!topicUrl.trim() || ntfyTesting} {ntfyTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
>
{ntfyTesting
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <FlaskConical className="w-3.5 h-3.5" />}
{ntfyTesting ? "Sending…" : "Test"} {ntfyTesting ? "Sending…" : "Test"}
</button> </button>
{settings?.ntfy_enabled && ( {settings?.ntfy_enabled && (
<button <button onClick={() => saveNtfy(false)} disabled={update.isPending}
onClick={() => saveNtfy(false)} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable Disable
</button> </button>
)} )}
</div> </div>
{ntfyTestResult && ( {ntfyTestResult && <ResultBadge result={ntfyTestResult} />}
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
ntfyTestResult.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{ntfyTestResult.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{ntfyTestResult.detail}
</div> </div>
</section>
{/* Quiet Hours */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Clock className="w-4 h-4" /> Quiet Hours
</h2>
<p className="text-xs text-muted-foreground mt-1">
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.
</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={quietEnabled} onChange={(e) => setQuietEnabled(e.target.checked)}
className="rounded" />
<span className="text-sm font-medium">Enable quiet hours</span>
</label>
{quietEnabled && (
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">From</label>
<select value={quietStart} onChange={(e) => setQuietStart(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">To</label>
<select value={quietEnd} onChange={(e) => setQuietEnd(Number(e.target.value))}
className="px-2 py-1.5 text-sm bg-background border border-border rounded-md">
{HOURS.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
{quietStart > quietEnd && (
<span className="text-xs text-muted-foreground">(overnight window)</span>
)} )}
</div> </div>
)}
<button onClick={saveQuietHours} 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">
{quietSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
{quietSaved ? "Saved!" : "Save Quiet Hours"}
</button>
</section>
{/* Digest */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Calendar className="w-4 h-4" /> Digest Mode
</h2>
<p className="text-xs text-muted-foreground mt-1">
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.
</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={digestEnabled} onChange={(e) => setDigestEnabled(e.target.checked)}
className="rounded" />
<span className="text-sm font-medium">Enable digest mode</span>
</label>
{digestEnabled && (
<div className="flex items-center gap-3">
<label className="text-sm text-muted-foreground">Frequency</label>
<div className="flex gap-3">
{(["daily", "weekly"] as const).map((freq) => (
<label key={freq} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="digest-freq" value={freq} checked={digestFrequency === freq}
onChange={() => setDigestFrequency(freq)} />
<span className="text-sm capitalize">{freq}</span>
{freq === "weekly" && <span className="text-xs text-muted-foreground">(Mondays, 8 AM UTC)</span>}
{freq === "daily" && <span className="text-xs text-muted-foreground">(8 AM UTC)</span>}
</label>
))}
</div>
</div>
)}
<button onClick={saveDigest} 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">
{digestSaved ? <CheckCircle className="w-3.5 h-3.5" /> : null}
{digestSaved ? "Saved!" : "Save Digest Settings"}
</button>
</section> </section>
{/* RSS */} {/* RSS */}
@@ -298,15 +429,8 @@ export default function NotificationsPage() {
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code> <code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code>
<button <button onClick={() => { navigator.clipboard.writeText(rssUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
onClick={() => { 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 ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />} {copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-muted-foreground" />}
</button> </button>
</div> </div>
@@ -316,60 +440,89 @@ export default function NotificationsPage() {
<div className="space-y-3 pt-1 border-t border-border"> <div className="space-y-3 pt-1 border-t border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{!settings?.rss_enabled ? ( {!settings?.rss_enabled ? (
<button <button onClick={() => toggleRss(true)} disabled={update.isPending}
onClick={() => toggleRss(true)} 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">
disabled={update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />} {rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
{rssSaved ? "Enabled!" : "Enable RSS"} {rssSaved ? "Enabled!" : "Enable RSS"}
</button> </button>
) : ( ) : (
<button <button onClick={() => toggleRss(false)} disabled={update.isPending}
onClick={() => toggleRss(false)} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable RSS Disable RSS
</button> </button>
)} )}
{rssUrl && ( {rssUrl && (
<> <>
<button <button onClick={testRss} disabled={rssTesting}
onClick={testRss} className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors">
disabled={rssTesting} {rssTesting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <FlaskConical className="w-3.5 h-3.5" />}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
>
{rssTesting
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <FlaskConical className="w-3.5 h-3.5" />}
{rssTesting ? "Checking…" : "Test"} {rssTesting ? "Checking…" : "Test"}
</button> </button>
<button <button onClick={() => resetRss.mutate()} disabled={resetRss.isPending}
onClick={() => resetRss.mutate()}
disabled={resetRss.isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" 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" title="Generate a new URL — old URL will stop working">
> <RefreshCw className="w-3 h-3" /> Regenerate URL
<RefreshCw className="w-3 h-3" />
Regenerate URL
</button> </button>
</> </>
)} )}
</div> </div>
{rssTestResult && ( {rssTestResult && <ResultBadge result={rssTestResult} />}
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
rssTestResult.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{rssTestResult.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{rssTestResult.detail}
</div> </div>
</section>
{/* Notification History */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Recent Alerts
</h2>
<p className="text-xs text-muted-foreground mt-1">Last 50 notification events for your account.</p>
</div>
</div>
{historyLoading ? (
<p className="text-sm text-muted-foreground">Loading history</p>
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground">
No events yet. Follow some bills and check back after the next poll.
</p>
) : (
<div className="divide-y divide-border">
{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 (
<div key={event.id} className="flex items-start gap-3 py-3">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span>
{payload.bill_label && (
<Link href={`/bills/${event.bill_id}`}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
{payload.bill_label}
</Link>
)}
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
{payload.bill_title && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{payload.bill_title}</p>
)}
{payload.brief_summary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{payload.brief_summary}</p>
)} )}
</div> </div>
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
</div>
);
})}
</div>
)}
</section> </section>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Settings, Settings,
@@ -15,9 +15,11 @@ import {
ShieldOff, ShieldOff,
BarChart3, BarChart3,
Bell, Bell,
Shield,
Zap,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; 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"; import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [ const LLM_PROVIDERS = [
@@ -112,6 +114,23 @@ export default function SettingsPage() {
model?: string; model?: string;
} | null>(null); } | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [modeTestResults, setModeTestResults] = useState<Record<string, { status: string; detail: string }>>({});
const [modeTestRunning, setModeTestRunning] = useState<Record<string, boolean>>({});
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<Record<string, string>>({}); const [taskIds, setTaskIds] = useState<Record<string, string>>({});
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({}); const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
const [confirmDelete, setConfirmDelete] = useState<number | null>(null); const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
@@ -185,6 +204,87 @@ export default function SettingsPage() {
<span className="text-xs text-muted-foreground group-hover:text-foreground"></span> <span className="text-xs text-muted-foreground group-hover:text-foreground"></span>
</Link> </Link>
{/* Follow Mode Notification Testing */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Follow Mode Notifications
</h2>
<p className="text-xs text-muted-foreground mt-1">
Requires at least one bill followed and ntfy configured. Tests use your first followed bill.
</p>
</div>
<div className="divide-y divide-border">
{([
{
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 (
<div key={key} className="flex items-start gap-3 py-3.5">
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{result && (
<div className="flex items-start gap-1.5 text-xs mt-1">
{result.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-px" />
: <XCircle className="w-3.5 h-3.5 text-red-500 shrink-0 mt-px" />}
<span className={result.status === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{result.detail}
</span>
</div>
)}
</div>
<button
onClick={() => 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 ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
})}
</div>
</section>
{/* Analysis Status */} {/* Analysis Status */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4"> <section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2"> <h2 className="font-semibold flex items-center gap-2">

View File

@@ -62,7 +62,7 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
</div> </div>
<div className="flex flex-col items-end gap-2 shrink-0"> <div className="flex flex-col items-end gap-2 shrink-0">
<FollowButton type="bill" value={bill.bill_id} /> <FollowButton type="bill" value={bill.bill_id} supportsModes />
{score !== undefined && score > 0 && ( {score !== undefined && score > 0 && (
<div className={cn("flex items-center gap-1 text-xs font-medium", trendColor(score))}> <div className={cn("flex items-center gap-1 text-xs font-medium", trendColor(score))}>
<TrendingUp className="w-3 h-3" /> <TrendingUp className="w-3 h-3" />

View File

@@ -1,23 +1,63 @@
"use client"; "use client";
import { Heart } from "lucide-react"; import { useRef, useEffect, useState } from "react";
import { useAddFollow, useIsFollowing, useRemoveFollow } from "@/lib/hooks/useFollows"; import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { cn } from "@/lib/utils"; 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 { interface FollowButtonProps {
type: "bill" | "member" | "topic"; type: "bill" | "member" | "topic";
value: string; value: string;
label?: 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 existing = useIsFollowing(type, value);
const add = useAddFollow(); const add = useAddFollow();
const remove = useRemoveFollow(); const remove = useRemoveFollow();
const updateMode = useUpdateFollowMode();
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const isFollowing = !!existing; 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;
// 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 = () => { const handleClick = () => {
if (isFollowing && existing) { if (isFollowing && existing) {
remove.mutate(existing.id); remove.mutate(existing.id);
@@ -25,7 +65,6 @@ export function FollowButton({ type, value, label }: FollowButtonProps) {
add.mutate({ type, value }); add.mutate({ type, value });
} }
}; };
return ( return (
<button <button
onClick={handleClick} onClick={handleClick}
@@ -42,3 +81,84 @@ export function FollowButton({ type, value, label }: FollowButtonProps) {
</button> </button>
); );
} }
// Mode-aware follow button for bills
if (!isFollowing) {
return (
<button
onClick={() => 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"
>
<Heart className="w-3.5 h-3.5" />
{label || "Follow"}
</button>
);
}
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<FollowMode, string> = {
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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => 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
)}
>
<ModeIcon className={cn("w-3.5 h-3.5", currentMode === "neutral" && "fill-current")} />
{modeLabel}
<ChevronDown className="w-3 h-3 ml-0.5 opacity-70" />
</button>
{open && (
<div className="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-md shadow-lg z-50 py-1">
{otherModes.map((mode) => {
const { label: optLabel, icon: OptIcon } = MODES[mode];
return (
<button
key={mode}
onClick={() => 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"
>
<span className="flex items-center gap-1.5 font-medium">
<OptIcon className="w-3.5 h-3.5" />
Switch to {optLabel}
</span>
<span className="text-xs text-muted-foreground pl-5">{modeDescriptions[mode]}</span>
</button>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={handleUnfollow}
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
>
Unfollow
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import type {
MemberTrendScore, MemberTrendScore,
MemberNewsArticle, MemberNewsArticle,
NewsArticle, NewsArticle,
NotificationEvent,
NotificationSettings, NotificationSettings,
PaginatedResponse, PaginatedResponse,
SettingsData, SettingsData,
@@ -98,6 +99,8 @@ export const followsAPI = {
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data), apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
remove: (id: number) => remove: (id: number) =>
apiClient.delete(`/api/follows/${id}`), apiClient.delete(`/api/follows/${id}`),
updateMode: (id: number, mode: string) =>
apiClient.patch<Follow>(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
}; };
// Dashboard // Dashboard
@@ -189,6 +192,10 @@ export const notificationsAPI = {
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data), apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () => testRss: () =>
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data), apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
testFollowMode: (mode: string, event_type: string) =>
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
getHistory: () =>
apiClient.get<NotificationEvent[]>("/api/notifications/history").then((r) => r.data),
}; };
// Admin // Admin

View File

@@ -30,3 +30,12 @@ export function useIsFollowing(type: string, value: string) {
const { data: follows = [] } = useFollows(); const { data: follows = [] } = useFollows();
return follows.find((f) => f.follow_type === type && f.follow_value === value); 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"] }),
});
}

View File

@@ -138,6 +138,7 @@ export interface Follow {
id: number; id: number;
follow_type: "bill" | "member" | "topic"; follow_type: "bill" | "member" | "topic";
follow_value: string; follow_value: string;
follow_mode: "neutral" | "pocket_veto" | "pocket_boost";
created_at: string; created_at: string;
} }
@@ -164,4 +165,22 @@ export interface NotificationSettings {
ntfy_enabled: boolean; ntfy_enabled: boolean;
rss_enabled: boolean; rss_enabled: boolean;
rss_token: string | null; 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;
} }

View File

@@ -8,26 +8,20 @@ http {
sendfile on; sendfile on;
keepalive_timeout 65; 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; resolver 127.0.0.11 valid=10s ipv6=off;
upstream api {
server api:8000;
}
upstream frontend {
server frontend:3000;
}
server { server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 10M; client_max_body_size 10M;
# API # API — variable forces re-resolution via resolver on each request cycle
location /api/ { location /api/ {
proxy_pass http://api; set $api http://api:8000;
proxy_pass $api;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -38,14 +32,16 @@ http {
# Next.js static assets (long cache) # Next.js static assets (long cache)
location /_next/static/ { location /_next/static/ {
proxy_pass http://frontend; set $frontend http://frontend:3000;
proxy_pass $frontend;
proxy_cache_valid 200 1d; proxy_cache_valid 200 1d;
add_header Cache-Control "public, max-age=86400, immutable"; add_header Cache-Control "public, max-age=86400, immutable";
} }
# Everything else → frontend # Everything else → frontend
location / { location / {
proxy_pass http://frontend; set $frontend http://frontend:3000;
proxy_pass $frontend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;