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:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Settings,
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
ShieldOff,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Shield,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { settingsAPI, adminAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
|
||||
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
const LLM_PROVIDERS = [
|
||||
@@ -112,6 +114,23 @@ export default function SettingsPage() {
|
||||
model?: string;
|
||||
} | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const [modeTestResults, setModeTestResults] = useState<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 [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
|
||||
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>
|
||||
</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 */}
|
||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user