Files
resolutionflow/frontend/src/components/layout/NotificationsPanel.tsx
chihlasm 0f750e63e0 feat(notifications): add Phase 4 Slice 2 — multi-channel notification system
Full notification infrastructure with in-app, email, Slack, and Teams channels:

Backend:
- NotificationConfig, NotificationLog, Notification models + migration
- Notification service with event routing, channel delivery, retry logic
- 9 API endpoints (config CRUD + in-app notifications)
- APScheduler retry job with exponential backoff (30s, 2m, 10m)
- Wired into escalation, proposal approval, and knowledge flywheel
- Pydantic event key validation, cross-tenant protection on recipients

Frontend:
- TypeScript types + API client for all notification endpoints
- NotificationsPanel: bell icon with unread badge, dropdown, mark-read
- NotificationSettings: channel config, event toggles, test, delete confirm
- Notifications tab on IntegrationsPage
- ARIA attributes, Escape handler, settings link on panel

Review fixes (13 issues resolved):
- notify() no longer commits/rolls back caller's transaction (critical)
- retry_failed_notifications returns count instead of None (critical)
- NotificationSettings moved inside dedicated tab (critical)
- target_user_ids scoped by account_id (security)
- Email loop collects all failures before raising
- Slack webhook validates response body
- events_enabled rejects unknown event keys
- link column widened to String(500)
- Dead code removed from _auto_reinforce
- Delete confirmation, ARIA, Escape key support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:37:54 +00:00

183 lines
6.4 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import {
Bell,
AlertTriangle,
AlertCircle,
FileText,
CheckCircle,
TrendingUp,
} from 'lucide-react'
import { notificationsApi } from '@/api/notifications'
import type { AppNotification } from '@/types/notification'
function timeAgo(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
function EventIcon({ event }: { event: string }) {
switch (event) {
case 'session.escalated':
return <AlertTriangle size={16} className="text-amber-400" />
case 'session.high_priority':
return <AlertCircle size={16} className="text-rose-500" />
case 'proposal.pending':
return <FileText size={16} className="text-primary" />
case 'proposal.approved':
return <CheckCircle size={16} className="text-emerald-400" />
case 'knowledge_gap.detected':
return <TrendingUp size={16} className="text-amber-400" />
default:
return <Bell size={16} className="text-muted-foreground" />
}
}
export function NotificationsPanel() {
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<AppNotification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
// Poll unread count every 30 seconds
useEffect(() => {
const fetchCount = () => {
notificationsApi.unreadCount().then(setUnreadCount).catch(() => {})
}
fetchCount()
const interval = setInterval(fetchCount, 30000)
return () => clearInterval(interval)
}, [])
// Fetch full list when dropdown opens
useEffect(() => {
if (open) {
notificationsApi.list({ limit: 20 }).then(setNotifications).catch(() => {})
}
}, [open])
// Click outside to close
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const handleMarkAllRead = useCallback(async () => {
try {
await notificationsApi.markAllRead()
setUnreadCount(0)
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
} catch {
// silently ignore
}
}, [])
const handleNotificationClick = useCallback(async (notification: AppNotification) => {
try {
if (!notification.is_read) {
await notificationsApi.markRead(notification.id)
setUnreadCount(prev => Math.max(0, prev - 1))
setNotifications(prev =>
prev.map(n => (n.id === notification.id ? { ...n, is_read: true } : n))
)
}
} catch {
// silently ignore
}
setOpen(false)
if (notification.link) {
navigate(notification.link)
}
}, [navigate])
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="relative rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
aria-label={unreadCount > 0 ? `Notifications — ${unreadCount} unread` : 'Notifications'}
aria-haspopup="true"
aria-expanded={open}
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex min-w-[1.125rem] h-[1.125rem] items-center justify-center rounded-full bg-rose-500 text-white text-[0.625rem] font-semibold leading-none px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{open && (
<div
className="absolute right-0 z-50 mt-2 w-80 rounded-xl border border-border bg-card shadow-xl animate-scale-in"
role="dialog"
aria-label="Notifications"
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false) }}
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-heading font-semibold text-foreground">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
>
Mark all as read
</button>
)}
</div>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No notifications yet
</div>
) : (
<div className="max-h-72 overflow-y-auto divide-y divide-border">
{notifications.map(notification => (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent/50 transition-colors ${
!notification.is_read ? 'bg-primary/5' : ''
}`}
>
<div className="mt-0.5 shrink-0">
<EventIcon event={notification.event} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground truncate">{notification.title}</p>
{notification.body && (
<p className="text-[0.6875rem] text-muted-foreground truncate">
{notification.body}
</p>
)}
<p className="text-[0.6875rem] text-muted-foreground/60 mt-0.5">
{timeAgo(notification.created_at)}
</p>
</div>
</button>
))}
</div>
)}
<div className="border-t border-border px-4 py-2.5 text-center">
<Link
to="/account/integrations"
onClick={() => setOpen(false)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Notification settings
</Link>
</div>
</div>
)}
</div>
)
}