feat: per-user notifications (ntfy + RSS), deduplicated actions, backfill task

Notifications:
- New /notifications page accessible to all users (ntfy + RSS config)
- ntfy now supports no-auth, Bearer token, and HTTP Basic auth (for ACL-protected self-hosted servers)
- RSS enabled/disabled independently of ntfy; token auto-generated on first GET
- Notification settings removed from admin-only Settings page; replaced with link card
- Sidebar adds Notifications nav link for all users
- notification_dispatcher.py: fan-out now marks RSS events dispatched independently

Action history:
- Migration 0012: deduplicates existing bill_actions rows and adds UNIQUE(bill_id, action_date, action_text)
- congress_poller.py: replaces existence-check inserts with ON CONFLICT DO NOTHING (race-condition safe)
- Added backfill_all_bill_actions task (no date filter) + admin endpoint POST /backfill-all-actions

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 12:04:13 -05:00
parent 91790fd798
commit 2e2fefb795
22 changed files with 1006 additions and 164 deletions

View File

@@ -13,14 +13,11 @@ import {
Trash2,
ShieldCheck,
ShieldOff,
FileText,
Brain,
BarChart3,
Bell,
Copy,
Rss,
} from "lucide-react";
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
import Link from "next/link";
import { settingsAPI, adminAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [
@@ -80,27 +77,6 @@ export default function SettingsPage() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
const { data: notifSettings, refetch: refetchNotif } = useQuery({
queryKey: ["notification-settings"],
queryFn: () => notificationsAPI.getSettings(),
});
const updateNotif = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data),
onSuccess: () => refetchNotif(),
});
const resetRss = useMutation({
mutationFn: () => notificationsAPI.resetRssToken(),
onSuccess: () => refetchNotif(),
});
const [ntfyUrl, setNtfyUrl] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [notifSaved, setNotifSaved] = useState(false);
const [copied, setCopied] = useState(false);
// Live model list from provider API
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
queryKey: ["llm-models", settings?.llm_provider],
@@ -194,6 +170,21 @@ export default function SettingsPage() {
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
</div>
{/* Notifications link */}
<Link
href="/notifications"
className="flex items-center justify-between bg-card border border-border rounded-lg p-4 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-3">
<Bell className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
<div>
<div className="text-sm font-medium">Notification Settings</div>
<div className="text-xs text-muted-foreground">Configure ntfy push alerts and RSS feed per user</div>
</div>
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground"></span>
</Link>
{/* Analysis Status */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
@@ -488,113 +479,6 @@ export default function SettingsPage() {
</div>
</section>
{/* Notifications */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Notifications
</h2>
{/* ntfy */}
<div className="space-y-3">
<div>
<label className="text-sm font-medium">ntfy Topic URL</label>
<p className="text-xs text-muted-foreground mb-1.5">
Your ntfy topic use <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy.sh</a> (public) or your self-hosted server.
e.g. <code className="bg-muted px-1 rounded text-xs">https://ntfy.sh/your-topic</code>
</p>
<input
type="url"
placeholder="https://ntfy.sh/your-topic"
defaultValue={notifSettings?.ntfy_topic_url ?? ""}
onChange={(e) => setNtfyUrl(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"
/>
</div>
<div>
<label className="text-sm font-medium">ntfy Auth Token <span className="text-muted-foreground font-normal">(optional)</span></label>
<p className="text-xs text-muted-foreground mb-1.5">Required only for private/self-hosted topics with access control.</p>
<input
type="password"
placeholder="tk_..."
defaultValue={notifSettings?.ntfy_token ?? ""}
onChange={(e) => setNtfyToken(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"
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={() => {
updateNotif.mutate({
ntfy_topic_url: ntfyUrl || notifSettings?.ntfy_topic_url || "",
ntfy_token: ntfyToken || notifSettings?.ntfy_token || "",
ntfy_enabled: true,
}, {
onSuccess: () => { setNotifSaved(true); setTimeout(() => setNotifSaved(false), 2000); }
});
}}
disabled={updateNotif.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"
>
{notifSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{notifSaved ? "Saved!" : "Save & Enable"}
</button>
{notifSettings?.ntfy_enabled && (
<button
onClick={() => updateNotif.mutate({ ntfy_enabled: false })}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable
</button>
)}
{notifSettings?.ntfy_enabled && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle className="w-3 h-3" /> ntfy active
</span>
)}
</div>
</div>
{/* RSS */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-2">
<Rss className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">RSS Feed</span>
</div>
{notifSettings?.rss_token ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-muted px-2 py-1.5 rounded truncate">
{`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/api/notifications/feed/${notifSettings.rss_token}.xml`
);
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" />}
</button>
</div>
<button
onClick={() => resetRss.mutate()}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Regenerate URL (invalidates old link)
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Save your ntfy settings above to generate your personal RSS feed URL.
</p>
)}
</div>
</section>
{/* API Health */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
@@ -683,6 +567,15 @@ export default function SettingsPage() {
fn: adminAPI.triggerFetchActions,
status: "on-demand",
},
{
key: "backfill-actions",
name: "Backfill All Action Histories",
description: "One-time catch-up: fetch action histories for all bills that were imported before this feature existed. Run once to populate timelines across your full bill archive.",
fn: adminAPI.backfillAllActions,
status: stats ? (stats.bills_missing_actions > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_actions,
countLabel: "bills missing action history",
},
{
key: "sponsors",
name: "Backfill Sponsors",