feat(phase5.1+6): fallback material cleanup + notification batch refactor

Phase 5.1 — MATERIAL_PALETTE removal:
- Remove MATERIAL_PALETTE + _material_to_color() from step_processor.py
- build_part_colors() now returns {part→material_name} for Blender resolver

Phase 6 — Notification Center Refactor:
- Migration 051: add channel (activity|notification|alert) to audit_log,
  add frequency (immediate|daily|never) to notification_configs
- Three notification channels: activity (per-render), notification (batch
  order summaries), alert (admin infrastructure)
- Per-render emit_notification_sync calls demoted to channel=activity
- New emit_batch_render_notification_sync(): single summary notification
  when all order lines reach terminal state ("47/50 succeeded, 3 failed")
- Beat task batch_render_notifications every 60s: safety-net for missed
  batch notifications after order completion
- GET /notifications: defaults to channel IN (notification, alert);
  accepts ?channel=activity for activity feed
- Unread count badge counts only notification+alert channels
- Notifications.tsx: three tabs (Notifications | Activity | Alerts)
- NotificationSettings.tsx: frequency dropdown per event type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:20:07 +01:00
parent 10d05bd2e7
commit 89c44b846f
14 changed files with 640 additions and 56 deletions
+40 -6
View File
@@ -6,13 +6,14 @@ import {
updateNotificationConfig,
resetNotificationConfigs,
type NotificationConfig,
type NotificationFrequency,
} from '../api/notifications'
const EVENT_LABELS: Record<string, string> = {
'order.submitted': 'Order submitted',
'order.completed': 'Order completed',
'render.completed': 'Render completed',
'render.failed': 'Render failed',
'render.completed': 'Render completed (activity)',
'render.failed': 'Render failed (activity)',
'excel.imported': 'Excel imported',
}
@@ -22,6 +23,12 @@ const CHANNELS: Array<{ key: 'in_app' | 'email'; label: string; comingSoon?: boo
{ key: 'email', label: 'E-Mail', comingSoon: true },
]
const FREQUENCY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'immediate', label: 'Immediate' },
{ value: 'daily', label: 'Daily summary' },
{ value: 'never', label: 'Disabled' },
]
function Toggle({
enabled,
disabled,
@@ -61,8 +68,8 @@ export default function NotificationSettingsPage() {
})
const updateMutation = useMutation({
mutationFn: ({ eventType, channel, enabled }: { eventType: string; channel: string; enabled: boolean }) =>
updateNotificationConfig(eventType, channel, enabled),
mutationFn: ({ eventType, channel, enabled, frequency }: { eventType: string; channel: string; enabled: boolean; frequency?: NotificationFrequency }) =>
updateNotificationConfig(eventType, channel, enabled, frequency),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-configs'] }),
onError: () => toast.error('Failed to update setting'),
})
@@ -76,15 +83,20 @@ export default function NotificationSettingsPage() {
onError: () => toast.error('Failed to reset settings'),
})
// Build lookup map: eventType+channel → enabled
// Build lookup maps
const configMap = new Map<string, boolean>()
const frequencyMap = new Map<string, NotificationFrequency>()
for (const c of configs) {
configMap.set(`${c.event_type}:${c.channel}`, c.enabled)
frequencyMap.set(`${c.event_type}:${c.channel}`, c.frequency ?? 'immediate')
}
const isEnabled = (event: string, channel: string) =>
configMap.get(`${event}:${channel}`) ?? (channel === 'in_app')
const getFrequency = (event: string, channel: string): NotificationFrequency =>
frequencyMap.get(`${event}:${channel}`) ?? 'immediate'
return (
<div className="p-6 space-y-5 max-w-2xl">
{/* Header */}
@@ -127,12 +139,15 @@ export default function NotificationSettingsPage() {
)}
</th>
))}
<th className="px-4 py-3 text-center text-xs font-semibold text-content-muted uppercase tracking-wide w-40">
Frequency
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-default">
{isLoading ? (
<tr>
<td colSpan={CHANNELS.length + 1} className="px-4 py-8 text-center text-sm text-content-muted">
<td colSpan={CHANNELS.length + 2} className="px-4 py-8 text-center text-sm text-content-muted">
Loading...
</td>
</tr>
@@ -154,6 +169,25 @@ export default function NotificationSettingsPage() {
/>
</td>
))}
<td className="px-4 py-3 text-center">
<select
value={getFrequency(event, 'in_app')}
disabled={updateMutation.isPending || !isEnabled(event, 'in_app')}
onChange={e =>
updateMutation.mutate({
eventType: event,
channel: 'in_app',
enabled: isEnabled(event, 'in_app'),
frequency: e.target.value as NotificationFrequency,
})
}
className="text-xs border border-border-default rounded px-2 py-1 bg-surface text-content disabled:opacity-40"
>
{FREQUENCY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
</tr>
))
)}
+79 -6
View File
@@ -3,17 +3,19 @@ import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, CheckCheck,
Activity, ShieldAlert,
} from 'lucide-react'
import { clsx } from 'clsx'
import {
getNotifications, markAsRead, markOneAsRead,
type Notification,
type Notification, type NotificationChannel,
} from '../api/notifications'
import { useAuthStore } from '../store/auth'
const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<string, unknown> | null) => string; color: string }> = {
'order.submitted': { icon: Send, label: (d) => `Order ${d?.order_number ?? '?'} submitted`, color: 'text-blue-500' },
'order.processing': { icon: PlayCircle, label: (d) => `Order ${d?.order_number ?? '?'} is processing`, color: 'text-yellow-500' },
'order.completed': { icon: CheckCircle, label: (d) => `Order ${d?.order_number ?? '?'} completed`, color: 'text-status-success-text' },
'order.completed': { icon: CheckCircle, label: (d) => (d?.message as string) ?? `Order ${d?.order_number ?? '?'} completed`, color: 'text-status-success-text' },
'order.rejected': { icon: XCircle, label: (d) => `Order ${d?.order_number ?? '?'} was rejected`, color: 'text-red-500' },
'render.completed': { icon: Image, label: (d) => `Render done: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`, color: 'text-status-success-text' },
'render.failed': { icon: AlertTriangle, label: (d) => `Render failed: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`, color: 'text-red-500' },
@@ -36,15 +38,38 @@ function formatTime(ts: string): string {
const PAGE_SIZE = 30
type TabKey = 'notification' | 'activity' | 'alert'
interface Tab {
key: TabKey
label: string
icon: typeof Bell
channel: NotificationChannel
adminOnly?: boolean
}
const TABS: Tab[] = [
{ key: 'notification', label: 'Notifications', icon: Bell, channel: 'notification' },
{ key: 'activity', label: 'Activity', icon: Activity, channel: 'activity' },
{ key: 'alert', label: 'Alerts', icon: ShieldAlert, channel: 'alert', adminOnly: true },
]
export default function NotificationsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('notification')
const [unreadOnly, setUnreadOnly] = useState(false)
const [offset, setOffset] = useState(0)
const navigate = useNavigate()
const qc = useQueryClient()
const user = useAuthStore((s) => s.user)
const isAdminOrPM = user?.role === 'admin' || user?.role === 'project_manager'
const visibleTabs = TABS.filter(t => !t.adminOnly || isAdminOrPM)
const currentTab = visibleTabs.find(t => t.key === activeTab) ?? visibleTabs[0]
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'page', offset, unreadOnly],
queryFn: () => getNotifications({ limit: PAGE_SIZE, offset, unread_only: unreadOnly }),
queryKey: ['notifications', 'page', activeTab, offset, unreadOnly],
queryFn: () => getNotifications({ limit: PAGE_SIZE, offset, unread_only: unreadOnly, channel: currentTab.channel }),
staleTime: 5_000,
})
@@ -65,6 +90,12 @@ export default function NotificationsPage() {
}
}
function handleTabChange(key: TabKey) {
setActiveTab(key)
setOffset(0)
setUnreadOnly(false)
}
const items = data?.items ?? []
const total = data?.total ?? 0
const unreadCount = data?.unread_count ?? 0
@@ -98,7 +129,7 @@ export default function NotificationsPage() {
Unread
</button>
</div>
{unreadCount > 0 && (
{unreadCount > 0 && activeTab === 'notification' && (
<button
onClick={() => markAllMutation.mutate()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-accent border border-accent rounded-md hover:bg-accent-light transition-colors"
@@ -110,13 +141,47 @@ export default function NotificationsPage() {
</div>
</div>
{/* Tab bar */}
<div className="flex gap-1 mb-4 border-b border-border-default">
{visibleTabs.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => handleTabChange(tab.key)}
className={clsx(
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab.key
? 'border-accent text-accent'
: 'border-transparent text-content-muted hover:text-content',
)}
>
<Icon size={14} />
{tab.label}
</button>
)
})}
</div>
{/* Tab description */}
{activeTab === 'activity' && (
<p className="text-xs text-content-muted mb-3">
Per-render events. These are logged for audit purposes but do not appear in the bell dropdown.
</p>
)}
{activeTab === 'alert' && (
<p className="text-xs text-content-muted mb-3">
Infrastructure alerts visible to admins and project managers only.
</p>
)}
<div className="bg-surface rounded-lg border border-border-default divide-y divide-border-light">
{isLoading && (
<div className="py-12 text-center text-sm text-content-muted">Loading...</div>
)}
{!isLoading && !items.length && (
<div className="py-12 text-center text-sm text-content-muted">
{unreadOnly ? 'No unread notifications' : 'No notifications yet'}
{unreadOnly ? 'No unread notifications' : `No ${currentTab.label.toLowerCase()} yet`}
</div>
)}
{items.map((n) => {
@@ -143,6 +208,14 @@ export default function NotificationsPage() {
{cfg.label(n.details)}
</p>
<p className="text-xs text-content-muted mt-1">{formatTime(n.timestamp)}</p>
{n.action === 'order.completed' && n.details?.total != null && (
<p className="mt-1 text-xs text-content-muted">
{String(n.details.completed ?? 0)}/{String(n.details.total)} renders completed
{Number(n.details.failed ?? 0) > 0 && (
<span className="ml-1 text-red-500">· {String(n.details.failed)} failed</span>
)}
</p>
)}
{n.action === 'excel.import_warnings' && !!n.details?.warnings && (
<ul className="mt-1.5 text-xs text-content-secondary list-disc list-inside space-y-0.5">
{(n.details.warnings as string[]).slice(0, 3).map((w, i) => (