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

View File

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