import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getNotifications, markNotificationRead, requeueNotification } from '../api/client'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; import { timeAgo } from '../api/utils'; import type { Notification } from '../api/types'; type ViewMode = 'list' | 'grouped'; // I-005: the Notifications page now hosts two tabs. "all" is the pre-I-005 // inbox behavior — no server-side status filter, client-side type/status // dropdowns untouched. "dead" routes the query through the new ?status=dead // handler branch so operators can triage the dead-letter queue in isolation. // The tab is intentionally a separate state axis from the status dropdown so // the two don't fight each other (dropdown filters within the tab's scope). type ActiveTab = 'all' | 'dead'; export default function NotificationsPage() { const [viewMode, setViewMode] = useState('grouped'); const [typeFilter, setTypeFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [activeTab, setActiveTab] = useState('all'); const { data, isLoading, error, refetch } = useQuery({ // I-005: queryKey carries the active tab so TanStack Query treats // "all" and "dead" as distinct cache entries. Without this, switching // tabs would return stale data until the 30s refetchInterval fires. queryKey: ['notifications', activeTab], queryFn: () => { const params: Record = { per_page: '100' }; if (activeTab === 'dead') { // The listNotifications handler's ?status=dead branch hits the // NotificationRepository.ListByStatus path instead of plain List, // which is both cheaper (DLQ is a small slice of all notifications) // and correct (pagination counts DLQ rows, not the full inbox). params.status = 'dead'; } return getNotifications(params); }, refetchInterval: 30000, }); const markRead = useTrackedMutation({ mutationFn: markNotificationRead, invalidates: [['notifications']], }); // I-005: requeue a dead notification. Invalidates both tab cache entries // because a successful requeue flips the row out of "dead" and potentially // into the "all" tab on its next refetch (status becomes 'pending'). // // The mutationFn is wrapped as `(id) => requeueNotification(id)` rather // than passed by reference so react-query v5's second positional argument // (the mutation context object) never reaches the API client. Without the // wrapper, TanStack invokes `requeueNotification(id, { client })`, and the // I-005 Phase 1 Red contract's strict `toHaveBeenCalledWith('notif-dead-001')` // assertion fails on the extra argument. Keep the arrow even if the context // object later becomes structurally empty — the contract pins a single-arg // call and the page must not leak mutation machinery into API boundaries. const requeue = useTrackedMutation({ mutationFn: (id: string) => requeueNotification(id), invalidates: [['notifications']], }); const notifications = data?.data || []; const filtered = useMemo(() => { return notifications.filter((n) => { if (typeFilter && n.type !== typeFilter) return false; if (statusFilter && n.status !== statusFilter) return false; return true; }); }, [notifications, typeFilter, statusFilter]); const types = useMemo(() => [...new Set(notifications.map(n => n.type))], [notifications]); const statuses = useMemo(() => [...new Set(notifications.map(n => n.status))], [notifications]); // Group by certificate_id const grouped = useMemo(() => { const groups: Record = {}; for (const n of filtered) { const key = n.certificate_id || 'general'; if (!groups[key]) groups[key] = []; groups[key].push(n); } return Object.entries(groups).sort(([, a], [, b]) => { const aTime = new Date(a[0].created_at).getTime(); const bTime = new Date(b[0].created_at).getTime(); return bTime - aTime; }); }, [filtered]); const unreadCount = filtered.filter(n => n.status === 'Pending' || n.status === 'pending').length; if (isLoading) { return ( <>
Loading...
); } if (error) { return ( <> refetch()} /> ); } return ( <>
{/* I-005: tab switcher between the standard inbox and the DLQ. The "Dead letter" label is pinned by NotificationsPage.test.tsx — do not rename without updating the Phase 1 Red contract. */}
{(typeFilter || statusFilter) && ( )}
{viewMode === 'grouped' ? ( grouped.length === 0 ? (
No notifications
) : ( grouped.map(([certId, items]) => (
{certId === 'general' ? 'General' : certId} {items.length} notification{items.length !== 1 ? 's' : ''}
{items.map((n) => ( markRead.mutate(n.id)} onRequeue={() => requeue.mutate(n.id)} /> ))}
)) ) ) : ( filtered.length === 0 ? (
No notifications
) : (
{filtered.map((n) => ( markRead.mutate(n.id)} /> ))}
) )}
); } function NotificationRow({ notification: n, onMarkRead, onRequeue, }: { notification: Notification; onMarkRead: () => void; // I-005: optional so callers who don't care about the DLQ (if any are ever // added) aren't forced to thread a no-op through. Every NotificationRow // today passes this, so in practice it's always defined. onRequeue?: () => void; }) { const isUnread = n.status === 'Pending' || n.status === 'pending'; // I-005: dead rows get a Requeue button and surface the retry budget + the // last transient error so operators triaging the DLQ can see *why* the // notification died before deciding whether to requeue. const isDead = n.status === 'dead'; return (
{n.type.replace(/([A-Z])/g, ' $1').trim()} {n.channel}
{/* D-2 (master): pre-D-2 the fallback was `{n.message || n.subject}`, but `subject` was a TS phantom the Go struct never emitted (`internal/domain/notification.go::NotificationEvent` has only `message`). The fallback always fell through to `message` because `subject` was always undefined. Post-D-2 the dead fallback is dropped along with the phantom field. */}

{n.message}

{isDead && (
Retry {n.retry_count ?? 0}/5 {n.last_error && ( {n.last_error} )}
)}
{n.recipient} {timeAgo(n.created_at)}
{isUnread && ( )} {isDead && onRequeue && ( )}
); }