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>
))
)}