Merge feat/weekly-digest: weekly digest + local-time quiet hours
Co-Authored-By: Jack Levy
This commit is contained in:
@@ -54,7 +54,7 @@
|
||||
- [ ] **Collections / Watchlists** — `collections` (id, user_id, name, slug, is_public) + `collection_bills` join table. UI to create/manage collections and filter dashboard by collection. Shareable via public slug URL (read-only for non-owners).
|
||||
- [ ] **Personal Notes** — `bill_notes` table (user_id, bill_id, content, stance, tags, pinned). Shown on bill detail page. Private; optionally pin to top of the bill detail view.
|
||||
- [ ] **Shareable Links** — UUID token on briefs and collections → public read-only view, no login required. Same token system for both. No expiry by default. UUID (not sequential) to prevent enumeration.
|
||||
- [ ] **Weekly Digest** — Celery beat task (weekly), queries followed bills for changes in the past 7 days, formats a low-noise summary, dispatches via ntfy + RSS.
|
||||
- [x] **Weekly Digest** — Celery beat task (Monday 8:30 AM UTC), queries followed bills for changes in the past 7 days, formats a low-noise summary, dispatches via ntfy + RSS. Admin can trigger immediately from Manual Controls.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -238,6 +238,14 @@ async def resume_analysis(current_user: User = Depends(get_current_admin)):
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/trigger-weekly-digest")
|
||||
async def trigger_weekly_digest(current_user: User = Depends(get_current_admin)):
|
||||
"""Send the weekly bill activity summary to all eligible users now."""
|
||||
from app.workers.notification_dispatcher import send_weekly_digest
|
||||
task = send_weekly_digest.delay()
|
||||
return {"task_id": task.id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/trigger-trend-scores")
|
||||
async def trigger_trend_scores(current_user: User = Depends(get_current_admin)):
|
||||
from app.workers.trend_scorer import calculate_all_trend_scores
|
||||
|
||||
@@ -31,6 +31,7 @@ _EVENT_LABELS = {
|
||||
"new_document": "New Bill Text",
|
||||
"new_amendment": "Amendment Filed",
|
||||
"bill_updated": "Bill Updated",
|
||||
"weekly_digest": "Weekly Digest",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
|
||||
digest_frequency=prefs.get("digest_frequency", "daily"),
|
||||
quiet_hours_start=prefs.get("quiet_hours_start"),
|
||||
quiet_hours_end=prefs.get("quiet_hours_end"),
|
||||
timezone=prefs.get("timezone"),
|
||||
)
|
||||
|
||||
|
||||
@@ -96,10 +98,13 @@ async def update_notification_settings(
|
||||
prefs["quiet_hours_start"] = body.quiet_hours_start
|
||||
if body.quiet_hours_end is not None:
|
||||
prefs["quiet_hours_end"] = body.quiet_hours_end
|
||||
if body.timezone is not None:
|
||||
prefs["timezone"] = body.timezone
|
||||
# Allow clearing quiet hours by passing -1
|
||||
if body.quiet_hours_start == -1:
|
||||
prefs.pop("quiet_hours_start", None)
|
||||
prefs.pop("quiet_hours_end", None)
|
||||
prefs.pop("timezone", None)
|
||||
|
||||
user.notification_prefs = prefs
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ class NotificationSettingsResponse(BaseModel):
|
||||
# Digest
|
||||
digest_enabled: bool = False
|
||||
digest_frequency: str = "daily" # daily | weekly
|
||||
# Quiet hours (UTC hour integers 0-23, None = disabled)
|
||||
# Quiet hours — stored as local-time hour integers (0-23); timezone is IANA name
|
||||
quiet_hours_start: Optional[int] = None
|
||||
quiet_hours_end: Optional[int] = None
|
||||
timezone: Optional[str] = None # IANA name, e.g. "America/New_York"
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -37,6 +38,7 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
digest_frequency: Optional[str] = None
|
||||
quiet_hours_start: Optional[int] = None
|
||||
quiet_hours_end: Optional[int] = None
|
||||
timezone: Optional[str] = None # IANA name sent by the browser on save
|
||||
|
||||
|
||||
class NotificationEventSchema(BaseModel):
|
||||
|
||||
@@ -82,5 +82,9 @@ celery_app.conf.update(
|
||||
"task": "app.workers.notification_dispatcher.send_notification_digest",
|
||||
"schedule": crontab(hour=8, minute=0), # 8 AM UTC daily
|
||||
},
|
||||
"send-weekly-digest": {
|
||||
"task": "app.workers.notification_dispatcher.send_weekly_digest",
|
||||
"schedule": crontab(hour=8, minute=30, day_of_week=1), # Monday 8:30 AM UTC
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ _EVENT_TITLES = {
|
||||
"new_document": "New Bill Text",
|
||||
"new_amendment": "Amendment Filed",
|
||||
"bill_updated": "Bill Updated",
|
||||
"weekly_digest": "Weekly Digest",
|
||||
}
|
||||
|
||||
_EVENT_TAGS = {
|
||||
@@ -45,12 +46,27 @@ _EVENT_PRIORITY = {
|
||||
|
||||
|
||||
def _in_quiet_hours(prefs: dict, now: datetime) -> bool:
|
||||
"""Return True if the current UTC hour falls within the user's quiet window."""
|
||||
"""Return True if the current local time falls within the user's quiet window.
|
||||
|
||||
Quiet hours are stored as local-time hour integers. If the user has a stored
|
||||
IANA timezone name we convert ``now`` (UTC) to that zone before comparing.
|
||||
Falls back to UTC if the timezone is absent or unrecognised.
|
||||
"""
|
||||
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
|
||||
|
||||
tz_name = prefs.get("timezone")
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
h = now.astimezone(ZoneInfo(tz_name)).hour
|
||||
except Exception:
|
||||
h = now.hour # unrecognised timezone — degrade gracefully to UTC
|
||||
else:
|
||||
h = now.hour
|
||||
|
||||
if start <= end:
|
||||
return start <= h < end
|
||||
# Wraps midnight (e.g. 22 → 8)
|
||||
@@ -250,6 +266,137 @@ def _send_ntfy(
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.workers.notification_dispatcher.send_weekly_digest")
|
||||
def send_weekly_digest(self):
|
||||
"""
|
||||
Proactive week-in-review summary for followed bills.
|
||||
|
||||
Runs every Monday at 8:30 AM UTC. Queries bills followed by each user
|
||||
for any activity in the past 7 days and dispatches a low-noise summary
|
||||
via ntfy and/or creates a NotificationEvent for the RSS feed.
|
||||
|
||||
Unlike send_notification_digest (which bundles queued events), this task
|
||||
generates a fresh summary independent of the notification event queue.
|
||||
"""
|
||||
from app.config import settings as app_settings
|
||||
from app.models.bill import Bill
|
||||
|
||||
db = get_sync_db()
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=7)
|
||||
base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/")
|
||||
|
||||
users = db.query(User).all()
|
||||
ntfy_sent = 0
|
||||
rss_created = 0
|
||||
|
||||
for user in users:
|
||||
prefs = user.notification_prefs or {}
|
||||
ntfy_enabled = prefs.get("ntfy_enabled", False)
|
||||
ntfy_url = prefs.get("ntfy_topic_url", "").strip()
|
||||
rss_enabled = prefs.get("rss_enabled", False)
|
||||
ntfy_configured = ntfy_enabled and bool(ntfy_url)
|
||||
|
||||
if not ntfy_configured and not rss_enabled:
|
||||
continue
|
||||
|
||||
bill_follows = db.query(Follow).filter_by(
|
||||
user_id=user.id, follow_type="bill"
|
||||
).all()
|
||||
if not bill_follows:
|
||||
continue
|
||||
|
||||
bill_ids = [f.follow_value for f in bill_follows]
|
||||
|
||||
active_bills = (
|
||||
db.query(Bill)
|
||||
.filter(
|
||||
Bill.bill_id.in_(bill_ids),
|
||||
Bill.updated_at >= cutoff,
|
||||
)
|
||||
.order_by(Bill.updated_at.desc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not active_bills:
|
||||
continue
|
||||
|
||||
count = len(active_bills)
|
||||
anchor = active_bills[0]
|
||||
|
||||
summary_lines = []
|
||||
for bill in active_bills[:10]:
|
||||
lbl = _format_bill_label(bill)
|
||||
action = (bill.latest_action_text or "")[:80]
|
||||
summary_lines.append(f"• {lbl}: {action}" if action else f"• {lbl}")
|
||||
if count > 10:
|
||||
summary_lines.append(f" …and {count - 10} more")
|
||||
summary = "\n".join(summary_lines)
|
||||
|
||||
# Mark dispatched_at immediately so dispatch_notifications skips this event;
|
||||
# it still appears in the RSS feed since that endpoint reads all events.
|
||||
event = NotificationEvent(
|
||||
user_id=user.id,
|
||||
bill_id=anchor.bill_id,
|
||||
event_type="weekly_digest",
|
||||
dispatched_at=now,
|
||||
payload={
|
||||
"bill_label": "Weekly Digest",
|
||||
"bill_title": f"{count} followed bill{'s' if count != 1 else ''} had activity this week",
|
||||
"brief_summary": summary,
|
||||
"bill_count": count,
|
||||
"bill_url": f"{base_url}/bills/{anchor.bill_id}",
|
||||
},
|
||||
)
|
||||
db.add(event)
|
||||
rss_created += 1
|
||||
|
||||
if ntfy_configured:
|
||||
try:
|
||||
_send_weekly_digest_ntfy(count, summary, ntfy_url, prefs)
|
||||
ntfy_sent += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Weekly digest ntfy failed for user {user.id}: {e}")
|
||||
|
||||
db.commit()
|
||||
logger.info(f"send_weekly_digest: {ntfy_sent} ntfy sent, {rss_created} events created")
|
||||
return {"ntfy_sent": ntfy_sent, "rss_created": rss_created}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _format_bill_label(bill) -> str:
|
||||
_TYPE_MAP = {
|
||||
"hr": "H.R.", "s": "S.", "hjres": "H.J.Res.", "sjres": "S.J.Res.",
|
||||
"hconres": "H.Con.Res.", "sconres": "S.Con.Res.", "hres": "H.Res.", "sres": "S.Res.",
|
||||
}
|
||||
prefix = _TYPE_MAP.get(bill.bill_type.lower(), bill.bill_type.upper())
|
||||
return f"{prefix} {bill.bill_number}"
|
||||
|
||||
|
||||
def _send_weekly_digest_ntfy(count: int, summary: str, 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 Weekly — {count} bill{'s' if count != 1 else ''} updated",
|
||||
"Priority": "low",
|
||||
"Tags": "newspaper,calendar",
|
||||
}
|
||||
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}"
|
||||
|
||||
resp = requests.post(ntfy_url, data=summary.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()
|
||||
|
||||
@@ -16,10 +16,11 @@ 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 HOURS = Array.from({ length: 24 }, (_, i) => {
|
||||
const period = i < 12 ? "AM" : "PM";
|
||||
const hour = i === 0 ? 12 : i > 12 ? i - 12 : i;
|
||||
return { value: i, label: `${hour}:00 ${period}` };
|
||||
});
|
||||
|
||||
const EVENT_META: Record<string, { label: string; icon: typeof Bell; color: string }> = {
|
||||
new_document: { label: "New Bill Text", icon: FileText, color: "text-blue-500" },
|
||||
@@ -83,12 +84,23 @@ export default function NotificationsPage() {
|
||||
const [quietStart, setQuietStart] = useState(22);
|
||||
const [quietEnd, setQuietEnd] = useState(8);
|
||||
const [quietSaved, setQuietSaved] = useState(false);
|
||||
const [detectedTimezone, setDetectedTimezone] = useState<string>("");
|
||||
const [savedTimezone, setSavedTimezone] = useState<string | null>(null);
|
||||
|
||||
// Digest state
|
||||
const [digestEnabled, setDigestEnabled] = useState(false);
|
||||
const [digestFrequency, setDigestFrequency] = useState<"daily" | "weekly">("daily");
|
||||
const [digestSaved, setDigestSaved] = useState(false);
|
||||
|
||||
// Detect the browser's local timezone once on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
setDetectedTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
} catch {
|
||||
// very old browser — leave empty, dispatcher will fall back to UTC
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Populate from loaded settings
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
@@ -99,6 +111,7 @@ export default function NotificationsPage() {
|
||||
setPassword(settings.ntfy_password ?? "");
|
||||
setDigestEnabled(settings.digest_enabled ?? false);
|
||||
setDigestFrequency(settings.digest_frequency ?? "daily");
|
||||
setSavedTimezone(settings.timezone ?? null);
|
||||
if (settings.quiet_hours_start != null) {
|
||||
setQuietEnabled(true);
|
||||
setQuietStart(settings.quiet_hours_start);
|
||||
@@ -130,17 +143,20 @@ export default function NotificationsPage() {
|
||||
};
|
||||
|
||||
const saveQuietHours = () => {
|
||||
const onSuccess = () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); };
|
||||
if (quietEnabled) {
|
||||
update.mutate(
|
||||
{ quiet_hours_start: quietStart, quiet_hours_end: quietEnd },
|
||||
{ onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
|
||||
{
|
||||
quiet_hours_start: quietStart,
|
||||
quiet_hours_end: quietEnd,
|
||||
// Include the detected timezone so the dispatcher knows which local time to compare
|
||||
...(detectedTimezone ? { timezone: detectedTimezone } : {}),
|
||||
},
|
||||
{ onSuccess }
|
||||
);
|
||||
} else {
|
||||
// -1 signals the backend to clear the values
|
||||
update.mutate(
|
||||
{ quiet_hours_start: -1 },
|
||||
{ onSuccess: () => { setQuietSaved(true); setTimeout(() => setQuietSaved(false), 2000); } }
|
||||
);
|
||||
// -1 signals the backend to clear quiet hours + timezone
|
||||
update.mutate({ quiet_hours_start: -1 }, { onSuccess });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,7 +340,7 @@ export default function NotificationsPage() {
|
||||
</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.
|
||||
RSS is unaffected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -335,23 +351,33 @@ export default function NotificationsPage() {
|
||||
</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 className="space-y-3">
|
||||
<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 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>
|
||||
{detectedTimezone && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Times are in your local timezone: <span className="font-medium text-foreground">{detectedTimezone}</span>
|
||||
{savedTimezone && savedTimezone !== detectedTimezone && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · saved as {savedTimezone} — save to update</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -734,6 +734,13 @@ export default function SettingsPage() {
|
||||
count: stats?.pending_llm,
|
||||
countLabel: "bills pending analysis",
|
||||
},
|
||||
{
|
||||
key: "weekly-digest",
|
||||
name: "Send Weekly Digest",
|
||||
description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.",
|
||||
fn: adminAPI.triggerWeeklyDigest,
|
||||
status: "on-demand",
|
||||
},
|
||||
];
|
||||
|
||||
const maintenance: ControlItem[] = [
|
||||
|
||||
@@ -241,6 +241,8 @@ export const adminAPI = {
|
||||
apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
|
||||
resumeAnalysis: () =>
|
||||
apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
|
||||
triggerWeeklyDigest: () =>
|
||||
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
|
||||
getApiHealth: () =>
|
||||
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
|
||||
getTaskStatus: (taskId: string) =>
|
||||
|
||||
@@ -171,6 +171,7 @@ export interface NotificationSettings {
|
||||
digest_frequency: "daily" | "weekly";
|
||||
quiet_hours_start: number | null;
|
||||
quiet_hours_end: number | null;
|
||||
timezone: string | null; // IANA name, e.g. "America/New_York"
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
|
||||
Reference in New Issue
Block a user