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

@@ -9,6 +9,7 @@ import type {
MemberTrendScore,
MemberNewsArticle,
NewsArticle,
NotificationEvent,
NotificationSettings,
PaginatedResponse,
SettingsData,
@@ -98,6 +99,8 @@ export const followsAPI = {
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
remove: (id: number) =>
apiClient.delete(`/api/follows/${id}`),
updateMode: (id: number, mode: string) =>
apiClient.patch<Follow>(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
};
// Dashboard
@@ -189,6 +192,10 @@ export const notificationsAPI = {
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () =>
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
testFollowMode: (mode: string, event_type: string) =>
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
getHistory: () =>
apiClient.get<NotificationEvent[]>("/api/notifications/history").then((r) => r.data),
};
// Admin

View File

@@ -30,3 +30,12 @@ export function useIsFollowing(type: string, value: string) {
const { data: follows = [] } = useFollows();
return follows.find((f) => f.follow_type === type && f.follow_value === value);
}
export function useUpdateFollowMode() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, mode }: { id: number; mode: string }) =>
followsAPI.updateMode(id, mode),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
});
}

View File

@@ -138,6 +138,7 @@ export interface Follow {
id: number;
follow_type: "bill" | "member" | "topic";
follow_value: string;
follow_mode: "neutral" | "pocket_veto" | "pocket_boost";
created_at: string;
}
@@ -164,4 +165,22 @@ export interface NotificationSettings {
ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null;
digest_enabled: boolean;
digest_frequency: "daily" | "weekly";
quiet_hours_start: number | null;
quiet_hours_end: number | null;
}
export interface NotificationEvent {
id: number;
bill_id: string;
event_type: "new_document" | "new_amendment" | "bill_updated";
payload: {
bill_title?: string;
bill_label?: string;
brief_summary?: string;
bill_url?: string;
} | null;
dispatched_at: string | null;
created_at: string;
}