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>
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Bell, CheckCircle, Clock } from 'lucide-react'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { Session } from '@/types/session'
|
||||
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)
|
||||
@@ -12,23 +19,48 @@ function timeAgo(dateStr: string): string {
|
||||
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 [sessions, setSessions] = useState<Session[]>([])
|
||||
const [hasNew, setHasNew] = 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(() => {
|
||||
sessionsApi.list({ size: 8 })
|
||||
.then(data => {
|
||||
setSessions(data)
|
||||
// Mark as "new" if any session was updated in the last hour
|
||||
const oneHourAgo = Date.now() - 3600000
|
||||
setHasNew(data.some(s => s.started_at && new Date(s.started_at).getTime() > oneHourAgo))
|
||||
})
|
||||
.catch(() => {})
|
||||
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)
|
||||
@@ -37,67 +69,112 @@ export function NotificationsPanel() {
|
||||
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); setHasNew(false) }}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
||||
title="Notifications"
|
||||
aria-label={unreadCount > 0 ? `Notifications — ${unreadCount} unread` : 'Notifications'}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Bell size={18} />
|
||||
{hasNew && (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-primary" />
|
||||
{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">
|
||||
<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">Activity</h3>
|
||||
<Link
|
||||
to="/sessions"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[0.6875rem] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No recent activity
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto divide-y divide-border">
|
||||
{sessions.map(session => (
|
||||
<Link
|
||||
key={session.id}
|
||||
to={`/sessions/${session.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-accent/50 transition-colors"
|
||||
{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">
|
||||
{session.completed_at ? (
|
||||
<CheckCircle size={16} className="text-emerald-400" />
|
||||
) : (
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
)}
|
||||
<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">
|
||||
{session.tree_snapshot?.name || 'Session'}
|
||||
</p>
|
||||
<p className="text-[0.6875rem] text-muted-foreground">
|
||||
{session.completed_at
|
||||
? `Completed ${timeAgo(session.completed_at)}`
|
||||
: session.started_at ? `Started ${timeAgo(session.started_at)}` : 'Not started'}
|
||||
{session.client_name && ` · ${session.client_name}`}
|
||||
<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>
|
||||
</Link>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user