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:
@@ -1,5 +1,7 @@
|
||||
import api from './client'
|
||||
|
||||
export type NotificationChannel = 'notification' | 'activity' | 'alert'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
action: string
|
||||
@@ -8,6 +10,7 @@ export interface Notification {
|
||||
details: Record<string, unknown> | null
|
||||
timestamp: string
|
||||
read_at: string | null
|
||||
channel?: NotificationChannel
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
@@ -20,6 +23,7 @@ export async function getNotifications(params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
unread_only?: boolean
|
||||
channel?: NotificationChannel
|
||||
}): Promise<NotificationListResponse> {
|
||||
const { data } = await api.get('/notifications', { params })
|
||||
return data
|
||||
@@ -40,12 +44,15 @@ export async function markOneAsRead(id: string): Promise<void> {
|
||||
|
||||
// ── Notification Config ───────────────────────────────────────────────────
|
||||
|
||||
export type NotificationFrequency = 'immediate' | 'daily' | 'never'
|
||||
|
||||
export interface NotificationConfig {
|
||||
id: string
|
||||
user_id: string
|
||||
event_type: string
|
||||
channel: 'in_app' | 'email'
|
||||
enabled: boolean
|
||||
frequency: NotificationFrequency
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -57,11 +64,14 @@ export async function getNotificationConfigs(): Promise<NotificationConfig[]> {
|
||||
export async function updateNotificationConfig(
|
||||
eventType: string,
|
||||
channel: string,
|
||||
enabled: boolean
|
||||
enabled: boolean,
|
||||
frequency?: NotificationFrequency
|
||||
): Promise<NotificationConfig> {
|
||||
const body: Record<string, unknown> = { enabled }
|
||||
if (frequency !== undefined) body.frequency = frequency
|
||||
const res = await api.put<NotificationConfig>(
|
||||
`/notifications/config/${encodeURIComponent(eventType)}/${channel}`,
|
||||
{ enabled }
|
||||
body
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user