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>
183 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|