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,44 +1,164 @@
"use client";
import { Heart } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow } from "@/lib/hooks/useFollows";
import { useRef, useEffect, useState } from "react";
import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { cn } from "@/lib/utils";
const MODES = {
neutral: {
label: "Following",
icon: Heart,
color: "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400",
},
pocket_veto: {
label: "Pocket Veto",
icon: Shield,
color: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400",
},
pocket_boost: {
label: "Pocket Boost",
icon: Zap,
color: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400",
},
} as const;
type FollowMode = keyof typeof MODES;
interface FollowButtonProps {
type: "bill" | "member" | "topic";
value: string;
label?: string;
supportsModes?: boolean;
}
export function FollowButton({ type, value, label }: FollowButtonProps) {
export function FollowButton({ type, value, label, supportsModes = false }: FollowButtonProps) {
const existing = useIsFollowing(type, value);
const add = useAddFollow();
const remove = useRemoveFollow();
const updateMode = useUpdateFollowMode();
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const isFollowing = !!existing;
const isPending = add.isPending || remove.isPending;
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
const isPending = add.isPending || remove.isPending || updateMode.isPending;
const handleClick = () => {
if (isFollowing && existing) {
remove.mutate(existing.id);
} else {
add.mutate({ type, value });
}
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
// Simple toggle for non-bill follows
if (!supportsModes) {
const handleClick = () => {
if (isFollowing && existing) {
remove.mutate(existing.id);
} else {
add.mutate({ type, value });
}
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
isFollowing
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"}
</button>
);
}
// Mode-aware follow button for bills
if (!isFollowing) {
return (
<button
onClick={() => add.mutate({ type, value })}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Heart className="w-3.5 h-3.5" />
{label || "Follow"}
</button>
);
}
const { label: modeLabel, icon: ModeIcon, color } = MODES[currentMode];
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
const switchMode = (mode: FollowMode) => {
if (existing) updateMode.mutate({ id: existing.id, mode });
setOpen(false);
};
const handleUnfollow = () => {
if (existing) remove.mutate(existing.id);
setOpen(false);
};
const modeDescriptions: Record<FollowMode, string> = {
neutral: "Alert me on all material changes",
pocket_veto: "Alert me only if this bill advances toward passage",
pocket_boost: "Alert me on all changes + remind me to contact my rep",
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
isFollowing
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen((v) => !v)}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
color
)}
>
<ModeIcon className={cn("w-3.5 h-3.5", currentMode === "neutral" && "fill-current")} />
{modeLabel}
<ChevronDown className="w-3 h-3 ml-0.5 opacity-70" />
</button>
{open && (
<div className="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-md shadow-lg z-50 py-1">
{otherModes.map((mode) => {
const { label: optLabel, icon: OptIcon } = MODES[mode];
return (
<button
key={mode}
onClick={() => switchMode(mode)}
title={modeDescriptions[mode]}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex flex-col gap-0.5"
>
<span className="flex items-center gap-1.5 font-medium">
<OptIcon className="w-3.5 h-3.5" />
Switch to {optLabel}
</span>
<span className="text-xs text-muted-foreground pl-5">{modeDescriptions[mode]}</span>
</button>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={handleUnfollow}
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
>
Unfollow
</button>
</div>
</div>
)}
>
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"}
</button>
</div>
);
}