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:
@@ -27,3 +27,4 @@ export { sessionToFlowApi } from './sessionToFlow'
|
||||
export { aiSessionsApi } from './aiSessions'
|
||||
export { flowProposalsApi } from './flowProposals'
|
||||
export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
|
||||
export { notificationsApi } from './notifications'
|
||||
|
||||
57
frontend/src/api/notifications.ts
Normal file
57
frontend/src/api/notifications.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
NotificationConfig,
|
||||
NotificationConfigCreate,
|
||||
NotificationConfigUpdate,
|
||||
AppNotification,
|
||||
UnreadCount,
|
||||
} from '@/types/notification'
|
||||
|
||||
export const notificationsApi = {
|
||||
async listConfigs(): Promise<NotificationConfig[]> {
|
||||
const response = await apiClient.get<NotificationConfig[]>('/notifications/configs')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createConfig(data: NotificationConfigCreate): Promise<NotificationConfig> {
|
||||
const response = await apiClient.post<NotificationConfig>('/notifications/configs', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateConfig(id: string, data: NotificationConfigUpdate): Promise<NotificationConfig> {
|
||||
const response = await apiClient.patch<NotificationConfig>(`/notifications/configs/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteConfig(id: string): Promise<void> {
|
||||
await apiClient.delete(`/notifications/configs/${id}`)
|
||||
},
|
||||
|
||||
async testConfig(configId: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/notifications/configs/test',
|
||||
{ config_id: configId }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async list(params?: { skip?: number; limit?: number }): Promise<AppNotification[]> {
|
||||
const response = await apiClient.get<AppNotification[]>('/notifications', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async unreadCount(): Promise<number> {
|
||||
const response = await apiClient.get<UnreadCount>('/notifications/unread-count')
|
||||
return response.data.count
|
||||
},
|
||||
|
||||
async markRead(id: string): Promise<void> {
|
||||
await apiClient.patch(`/notifications/${id}/read`)
|
||||
},
|
||||
|
||||
async markAllRead(): Promise<void> {
|
||||
await apiClient.post('/notifications/mark-all-read')
|
||||
},
|
||||
}
|
||||
|
||||
export default notificationsApi
|
||||
440
frontend/src/components/account/NotificationSettings.tsx
Normal file
440
frontend/src/components/account/NotificationSettings.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Send,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { notificationsApi } from '@/api/notifications'
|
||||
import type { NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/types/notification'
|
||||
import { NOTIFICATION_EVENTS, CHANNEL_LABELS } from '@/types/notification'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
|
||||
type ChannelType = 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
|
||||
const CHANNEL_ICONS: Record<ChannelType, React.ElementType> = {
|
||||
email: Mail,
|
||||
slack_webhook: Hash,
|
||||
teams_webhook: MessageSquare,
|
||||
}
|
||||
|
||||
function maskWebhookUrl(url: string): string {
|
||||
if (url.length <= 8) return url
|
||||
return '\u2022'.repeat(12) + url.slice(-8)
|
||||
}
|
||||
|
||||
export function NotificationSettings() {
|
||||
const [configs, setConfigs] = useState<NotificationConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addingChannel, setAddingChannel] = useState<ChannelType | null>(null)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add form state
|
||||
const [newWebhookUrl, setNewWebhookUrl] = useState('')
|
||||
const [newEmails, setNewEmails] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs()
|
||||
}, [])
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showDropdown])
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await notificationsApi.listConfigs()
|
||||
setConfigs(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load notification configs:', err)
|
||||
toast.error('Failed to load notification settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddChannel = (channel: ChannelType) => {
|
||||
setAddingChannel(channel)
|
||||
setShowDropdown(false)
|
||||
setNewWebhookUrl('')
|
||||
setNewEmails('')
|
||||
}
|
||||
|
||||
const handleSaveNew = async () => {
|
||||
if (!addingChannel) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const payload: NotificationConfigCreate = { channel: addingChannel }
|
||||
if (addingChannel === 'email') {
|
||||
const emails = newEmails.split(',').map(e => e.trim()).filter(Boolean)
|
||||
if (emails.length === 0) {
|
||||
toast.error('Please enter at least one email address')
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
payload.email_addresses = emails
|
||||
} else {
|
||||
if (!newWebhookUrl.trim()) {
|
||||
toast.error('Please enter a webhook URL')
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
payload.webhook_url = newWebhookUrl.trim()
|
||||
}
|
||||
await notificationsApi.createConfig(payload)
|
||||
await loadConfigs()
|
||||
setAddingChannel(null)
|
||||
setNewWebhookUrl('')
|
||||
setNewEmails('')
|
||||
toast.success(`${CHANNEL_LABELS[addingChannel]} channel added`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create notification config:', err)
|
||||
toast.error('Failed to add channel')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (config: NotificationConfig) => {
|
||||
try {
|
||||
const update: NotificationConfigUpdate = { is_active: !config.is_active }
|
||||
await notificationsApi.updateConfig(config.id, update)
|
||||
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, is_active: !c.is_active } : c))
|
||||
toast.success(config.is_active ? 'Channel disabled' : 'Channel enabled')
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle config:', err)
|
||||
toast.error('Failed to update channel')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEvent = async (config: NotificationConfig, eventKey: string) => {
|
||||
const updated = { ...config.events_enabled, [eventKey]: !config.events_enabled[eventKey] }
|
||||
try {
|
||||
await notificationsApi.updateConfig(config.id, { events_enabled: updated })
|
||||
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, events_enabled: updated } : c))
|
||||
} catch (err) {
|
||||
console.error('Failed to update events:', err)
|
||||
toast.error('Failed to update event settings')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (configId: string) => {
|
||||
setTestingId(configId)
|
||||
try {
|
||||
const result = await notificationsApi.testConfig(configId)
|
||||
if (result.success) {
|
||||
toast.success(result.message || 'Test notification sent')
|
||||
} else {
|
||||
toast.error(result.message || 'Test failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test config:', err)
|
||||
toast.error('Test notification failed')
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
try {
|
||||
await notificationsApi.deleteConfig(configId)
|
||||
setConfigs(prev => prev.filter(c => c.id !== configId))
|
||||
toast.success('Channel removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete config:', err)
|
||||
toast.error('Failed to remove channel')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold font-heading text-foreground">Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Channel
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-1 z-20 w-48 rounded-xl border border-border bg-card shadow-xl">
|
||||
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => {
|
||||
const Icon = CHANNEL_ICONS[key]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleAddChannel(key)}
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-[rgba(255,255,255,0.04)] first:rounded-t-xl last:rounded-b-xl transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && configs.length === 0 && !addingChannel && (
|
||||
<div className="glass-card-static p-6 text-center">
|
||||
<Bell className="mx-auto h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No notification channels configured. Add a channel to receive alerts for session events.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel list */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{configs.map(config => {
|
||||
const Icon = CHANNEL_ICONS[config.channel]
|
||||
return (
|
||||
<div key={config.id} className="glass-card-static p-5">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{CHANNEL_LABELS[config.channel]}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-2 w-2 rounded-full',
|
||||
config.is_active ? 'bg-emerald-400' : 'bg-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-xs font-label',
|
||||
config.is_active ? 'text-emerald-400' : 'text-muted-foreground'
|
||||
)}>
|
||||
{config.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Config details */}
|
||||
<div className="mb-4">
|
||||
{config.webhook_url && (
|
||||
<div>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Webhook URL
|
||||
</span>
|
||||
<p className="mt-0.5 text-sm text-foreground font-mono">
|
||||
{maskWebhookUrl(config.webhook_url)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{config.email_addresses && config.email_addresses.length > 0 && (
|
||||
<div>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Email Addresses
|
||||
</span>
|
||||
<p className="mt-0.5 text-sm text-foreground">
|
||||
{config.email_addresses.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event toggles */}
|
||||
<div className="mb-4">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Events
|
||||
</span>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(NOTIFICATION_EVENTS).map(([eventKey, eventLabel]) => (
|
||||
<label
|
||||
key={eventKey}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.events_enabled[eventKey] ?? false}
|
||||
onChange={() => handleToggleEvent(config, eventKey)}
|
||||
className="h-3.5 w-3.5 rounded border-border bg-card text-primary focus:ring-primary/30 focus:ring-offset-0 cursor-pointer accent-[#06b6d4]"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{eventLabel}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => handleToggleActive(config)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
||||
)}
|
||||
>
|
||||
{config.is_active ? (
|
||||
<ToggleRight className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{config.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleTest(config.id)}
|
||||
disabled={testingId === config.id}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)] transition-all',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{testingId === config.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
Test
|
||||
</button>
|
||||
|
||||
{confirmDeleteId === config.id ? (
|
||||
<button
|
||||
onClick={() => { handleDelete(config.id); setConfirmDeleteId(null) }}
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-sm text-red-400 hover:text-red-300 transition-colors font-medium"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Confirm Remove
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDeleteId(config.id)}
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Inline add form */}
|
||||
{addingChannel && (
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{(() => {
|
||||
const Icon = CHANNEL_ICONS[addingChannel]
|
||||
return <Icon className="h-5 w-5 text-muted-foreground" />
|
||||
})()}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Add {CHANNEL_LABELS[addingChannel]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{addingChannel === 'email' ? (
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Email Addresses
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={newEmails}
|
||||
onChange={(e) => setNewEmails(e.target.value)}
|
||||
placeholder="user@example.com, team@example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Separate multiple addresses with commas
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Webhook URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={newWebhookUrl}
|
||||
onChange={(e) => setNewWebhookUrl(e.target.value)}
|
||||
placeholder={
|
||||
addingChannel === 'slack_webhook'
|
||||
? 'https://hooks.slack.com/services/...'
|
||||
: 'https://outlook.office.com/webhook/...'
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
onClick={handleSaveNew}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 active:scale-[0.97] transition-all',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddingChannel(null)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationSettings
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
|
||||
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save, Bell } from 'lucide-react'
|
||||
import { NotificationSettings } from '@/components/account/NotificationSettings'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { IntegrationIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
@@ -41,7 +42,7 @@ const emptyForm: ConnectionForm = {
|
||||
private_key: '',
|
||||
}
|
||||
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings'
|
||||
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings' | 'notifications'
|
||||
|
||||
export function IntegrationsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('connection')
|
||||
@@ -237,6 +238,7 @@ export function IntegrationsPage() {
|
||||
{ id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users },
|
||||
{ id: 'post-history' as Tab, label: 'Post History', icon: History },
|
||||
{ id: 'flowpilot-settings' as Tab, label: 'FlowPilot', icon: Zap },
|
||||
{ id: 'notifications' as Tab, label: 'Notifications', icon: Bell },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -555,6 +557,11 @@ export function IntegrationsPage() {
|
||||
{activeTab === 'flowpilot-settings' && (
|
||||
<FlowPilotSettingsTab connection={connection} />
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationSettings />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -93,3 +93,4 @@ export type {
|
||||
|
||||
export * from './scripts'
|
||||
export * from './integrations'
|
||||
export * from './notification'
|
||||
|
||||
52
frontend/src/types/notification.ts
Normal file
52
frontend/src/types/notification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface NotificationConfig {
|
||||
id: string
|
||||
channel: 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
webhook_url: string | null
|
||||
email_addresses: string[] | null
|
||||
is_active: boolean
|
||||
events_enabled: Record<string, boolean>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NotificationConfigCreate {
|
||||
channel: 'email' | 'slack_webhook' | 'teams_webhook'
|
||||
webhook_url?: string
|
||||
email_addresses?: string[]
|
||||
events_enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface NotificationConfigUpdate {
|
||||
webhook_url?: string
|
||||
email_addresses?: string[]
|
||||
is_active?: boolean
|
||||
events_enabled?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
event: string
|
||||
title: string
|
||||
body: string | null
|
||||
link: string | null
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
'session.escalated': 'Session Escalated',
|
||||
'session.high_priority': 'High Priority Session',
|
||||
'proposal.pending': 'New Flow Proposal',
|
||||
'proposal.approved': 'Proposal Approved',
|
||||
'knowledge_gap.detected': 'Knowledge Gap Detected',
|
||||
} as const
|
||||
|
||||
export const CHANNEL_LABELS = {
|
||||
email: 'Email',
|
||||
slack_webhook: 'Slack',
|
||||
teams_webhook: 'Microsoft Teams',
|
||||
} as const
|
||||
Reference in New Issue
Block a user