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:
282
frontend/app/notifications/page.tsx
Normal file
282
frontend/app/notifications/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Bell, Rss, CheckCircle, Copy, RefreshCw } from "lucide-react";
|
||||
import { notificationsAPI } from "@/lib/api";
|
||||
|
||||
const AUTH_METHODS = [
|
||||
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" },
|
||||
{ value: "token", label: "Access token", hint: "ntfy token (tk_...)" },
|
||||
{ value: "basic", label: "Username & password", hint: "For servers behind HTTP basic auth or nginx ACL" },
|
||||
];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { data: settings, refetch } = useQuery({
|
||||
queryKey: ["notification-settings"],
|
||||
queryFn: () => notificationsAPI.getSettings(),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
|
||||
notificationsAPI.updateSettings(data),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
const resetRss = useMutation({
|
||||
mutationFn: () => notificationsAPI.resetRssToken(),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
// ntfy form state
|
||||
const [topicUrl, setTopicUrl] = useState("");
|
||||
const [authMethod, setAuthMethod] = useState("none");
|
||||
const [token, setToken] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [ntfySaved, setNtfySaved] = useState(false);
|
||||
|
||||
// RSS state
|
||||
const [rssSaved, setRssSaved] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Populate from loaded settings
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setTopicUrl(settings.ntfy_topic_url ?? "");
|
||||
setAuthMethod(settings.ntfy_auth_method ?? "none");
|
||||
setToken(settings.ntfy_token ?? "");
|
||||
setUsername(settings.ntfy_username ?? "");
|
||||
setPassword(settings.ntfy_password ?? "");
|
||||
}, [settings]);
|
||||
|
||||
const saveNtfy = (enabled: boolean) => {
|
||||
update.mutate(
|
||||
{
|
||||
ntfy_topic_url: topicUrl,
|
||||
ntfy_auth_method: authMethod,
|
||||
ntfy_token: authMethod === "token" ? token : "",
|
||||
ntfy_username: authMethod === "basic" ? username : "",
|
||||
ntfy_password: authMethod === "basic" ? password : "",
|
||||
ntfy_enabled: enabled,
|
||||
},
|
||||
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
|
||||
);
|
||||
};
|
||||
|
||||
const toggleRss = (enabled: boolean) => {
|
||||
update.mutate(
|
||||
{ rss_enabled: enabled },
|
||||
{ onSuccess: () => { setRssSaved(true); setTimeout(() => setRssSaved(false), 2000); } }
|
||||
);
|
||||
};
|
||||
|
||||
const rssUrl = settings?.rss_token
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" /> Notifications
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Get alerted when bills you follow are updated, new text is published, or amendments are filed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ntfy */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" /> Push Notifications (ntfy)
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Uses <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a> — a free, open-source push notification service.
|
||||
Use the public <code className="bg-muted px-1 rounded">ntfy.sh</code> server or your own self-hosted instance.
|
||||
</p>
|
||||
</div>
|
||||
{settings?.ntfy_enabled && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Topic URL</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The full URL to your ntfy topic, e.g.{" "}
|
||||
<code className="bg-muted px-1 rounded">https://ntfy.sh/my-pocketveto-alerts</code>
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh/your-topic"
|
||||
value={topicUrl}
|
||||
onChange={(e) => setTopicUrl(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>
|
||||
|
||||
{/* Auth method */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Authentication</label>
|
||||
<div className="space-y-2">
|
||||
{AUTH_METHODS.map(({ value, label, hint }) => (
|
||||
<label key={value} className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="ntfy-auth"
|
||||
value={value}
|
||||
checked={authMethod === value}
|
||||
onChange={() => setAuthMethod(value)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{hint}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token input */}
|
||||
{authMethod === "token" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="tk_..."
|
||||
value={token}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic auth inputs */}
|
||||
{authMethod === "basic" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="your-username"
|
||||
value={username}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="your-password"
|
||||
value={password}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-1 border-t border-border">
|
||||
<button
|
||||
onClick={() => saveNtfy(true)}
|
||||
disabled={!topicUrl.trim() || update.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
||||
{ntfySaved ? "Saved!" : "Save & Enable"}
|
||||
</button>
|
||||
{settings?.ntfy_enabled && (
|
||||
<button
|
||||
onClick={() => saveNtfy(false)}
|
||||
disabled={update.isPending}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* RSS */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<Rss className="w-4 h-4" /> RSS Feed
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
A private, tokenized RSS feed of your bill alerts — subscribe in any RSS reader.
|
||||
Independent of ntfy; enable either or both.
|
||||
</p>
|
||||
</div>
|
||||
{settings?.rss_enabled && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400 shrink-0">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rssUrl && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Your feed URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-muted px-2 py-2 rounded truncate">{rssUrl}</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
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" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1 border-t border-border">
|
||||
{!settings?.rss_enabled ? (
|
||||
<button
|
||||
onClick={() => toggleRss(true)}
|
||||
disabled={update.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
|
||||
{rssSaved ? "Enabled!" : "Enable RSS"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => toggleRss(false)}
|
||||
disabled={update.isPending}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Disable RSS
|
||||
</button>
|
||||
)}
|
||||
{rssUrl && (
|
||||
<button
|
||||
onClick={() => resetRss.mutate()}
|
||||
disabled={resetRss.isPending}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Generate a new URL — old URL will stop working"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Regenerate URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Users,
|
||||
Tags,
|
||||
Heart,
|
||||
Bell,
|
||||
Settings,
|
||||
Landmark,
|
||||
LogOut,
|
||||
@@ -24,6 +25,7 @@ const NAV = [
|
||||
{ href: "/members", label: "Members", icon: Users, adminOnly: false },
|
||||
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false },
|
||||
{ href: "/following", label: "Following", icon: Heart, adminOnly: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false },
|
||||
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ export interface AnalysisStats {
|
||||
pending_llm: number;
|
||||
bills_missing_sponsor: number;
|
||||
bills_missing_metadata: number;
|
||||
bills_missing_actions: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
@@ -199,6 +200,8 @@ export const adminAPI = {
|
||||
apiClient.post("/api/admin/backfill-citations").then((r) => r.data),
|
||||
triggerFetchActions: () =>
|
||||
apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data),
|
||||
backfillAllActions: () =>
|
||||
apiClient.post("/api/admin/backfill-all-actions").then((r) => r.data),
|
||||
backfillMetadata: () =>
|
||||
apiClient.post("/api/admin/backfill-metadata").then((r) => r.data),
|
||||
resumeAnalysis: () =>
|
||||
|
||||
@@ -157,7 +157,11 @@ export interface SettingsData {
|
||||
|
||||
export interface NotificationSettings {
|
||||
ntfy_topic_url: string;
|
||||
ntfy_auth_method: string; // "none" | "token" | "basic"
|
||||
ntfy_token: string;
|
||||
ntfy_username: string;
|
||||
ntfy_password: string;
|
||||
ntfy_enabled: boolean;
|
||||
rss_enabled: boolean;
|
||||
rss_token: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user