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:
2026-03-19 12:37:54 +00:00
parent a8999adef3
commit 0f750e63e0
22 changed files with 3402 additions and 53 deletions

View File

@@ -27,3 +27,4 @@ export { sessionToFlowApi } from './sessionToFlow'
export { aiSessionsApi } from './aiSessions'
export { flowProposalsApi } from './flowProposals'
export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
export { notificationsApi } from './notifications'

View 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

View 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

View File

@@ -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>

View File

@@ -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>
</>
)

View File

@@ -93,3 +93,4 @@ export type {
export * from './scripts'
export * from './integrations'
export * from './notification'

View 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