Merge branch 'feat/phase-b2-per-event-prefs' into 'main'

feat(notifications): Phase B-2 — per-event-type granular preferences

See merge request 2-6/2-6-4/terravault/ordinis!219
This commit is contained in:
Александр Зимин
2026-05-15 16:53:32 +00:00
11 changed files with 420 additions and 175 deletions
@@ -12,22 +12,40 @@ import {
} from '@/ui'
import { useMyNotificationPreferences } from '@/api/queries'
import { useUpdateNotificationPreferences } from '@/api/mutations'
import type { NotificationPreferences } from '@/api/client'
import {
NOTIFICATION_EVENT_TYPES,
type ChannelToggles,
type NotificationEventType,
type NotificationPreferences,
} from '@/api/client'
export const Route = createFileRoute('/me/notifications/preferences')({
component: NotificationPreferencesPage,
})
const CHANNELS: { key: keyof ChannelToggles; disabled: boolean }[] = [
{ key: 'email', disabled: false },
{ key: 'express', disabled: true },
{ key: 'telegram', disabled: true },
]
const DEFAULT_TOGGLES: ChannelToggles = {
email: true,
express: false,
telegram: false,
}
/**
* Phase B — UI для per-channel notification opt-in/out.
* Phase B-2 matrix UI для per-(eventType, channel) preferences.
*
* <p>Email — единственный actually wired channel в Phase A/B (через Spring
* \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой
* "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher
* никогда не пошлёт пока channel bean не зарегистрирован.
* <p>4 events × 3 channels = 12 toggles. User can opt-out 'Submitted via
* email' (queue noise) keep 'Approved via email' (action confirmation).
*
* <p>Optimistic update — toggle flips мгновенно, network roundtrip ~200ms
* почти не виден. onError refetch восстанавливает state если PUT 4xx/5xx.
* <p>Express + Telegram disabled с подсказкой "Скоро" — backend хранит
* preferences (на случай early opt-in), но dispatcher не пошлёт пока channel
* bean не зарегистрирован.
*
* <p>Optimistic update — toggle flips мгновенно. onError refetch восстанавливает.
*/
function NotificationPreferencesPage() {
const { t } = useTranslation()
@@ -42,7 +60,7 @@ function NotificationPreferencesPage() {
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям.',
'Per-event настройки доставки. Применяется только к вашим уведомлениям.',
})}
/>
<EmptyState
@@ -70,58 +88,87 @@ function NotificationPreferencesPage() {
return <QueryErrorState error={prefsQ.error} onRetry={() => prefsQ.refetch()} />
}
const prefs: NotificationPreferences = prefsQ.data ?? {
email: true,
express: false,
telegram: false,
}
// Backend гарантирует что все KNOWN_EVENT_TYPES present с filled defaults.
// Если query возвращает пустоту (cold start), local-fill через DEFAULT_TOGGLES.
const prefs: NotificationPreferences = prefsQ.data ?? {}
const handleToggle = (channel: keyof NotificationPreferences, next: boolean) => {
updateMut.mutate({ ...prefs, [channel]: next })
const handleToggle = (
eventType: NotificationEventType,
channel: keyof ChannelToggles,
next: boolean,
) => {
const currentRow = prefs[eventType] ?? { ...DEFAULT_TOGGLES }
const updatedRow: ChannelToggles = { ...currentRow, [channel]: next }
const updatedPrefs: NotificationPreferences = { ...prefs, [eventType]: updatedRow }
updateMut.mutate(updatedPrefs)
}
return (
<Panel>
<PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
description={t('prefs.descriptionMatrix', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).',
'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.',
})}
/>
<div className="mt-6 space-y-1">
<ToggleRow
label={t('prefs.email.label', { defaultValue: 'Email' })}
description={t('prefs.email.description', {
defaultValue:
'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.',
})}
checked={prefs.email}
onChange={(v) => handleToggle('email', v)}
/>
<ToggleRow
label={t('prefs.express.label', { defaultValue: 'Express' })}
description={t('prefs.express.description', {
defaultValue:
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
})}
checked={prefs.express}
onChange={(v) => handleToggle('express', v)}
disabled
comingSoon
/>
<ToggleRow
label={t('prefs.telegram.label', { defaultValue: 'Telegram' })}
description={t('prefs.telegram.description', {
defaultValue:
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
})}
checked={prefs.telegram}
onChange={(v) => handleToggle('telegram', v)}
disabled
comingSoon
/>
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-line">
<th className="text-left py-3 pr-4 text-sm font-semibold text-ink-2">
{t('prefs.matrix.eventCol', { defaultValue: 'Событие' })}
</th>
{CHANNELS.map(({ key, disabled }) => (
<th
key={key}
className="text-center py-3 px-3 text-sm font-semibold text-ink-2 min-w-[7rem]"
>
<div className="inline-flex items-center gap-1">
{t(`prefs.${key}.label`, { defaultValue: capitalize(key) })}
{disabled && (
<span className="rounded bg-mute-bg px-1.5 py-0.5 text-[10px] text-mute font-normal">
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{NOTIFICATION_EVENT_TYPES.map((eventType) => {
const row = prefs[eventType] ?? DEFAULT_TOGGLES
return (
<tr key={eventType} className="border-b border-line last:border-b-0">
<td className="py-4 pr-4">
<div className="text-base font-medium text-ink">
{t(`prefs.event.${eventType}.label`, {
defaultValue: humanizeEventType(eventType),
})}
</div>
<p className="mt-0.5 text-sm text-ink-2">
{t(`prefs.event.${eventType}.description`, {
defaultValue: '',
})}
</p>
</td>
{CHANNELS.map(({ key, disabled }) => (
<td key={key} className="text-center py-4 px-3">
<Switch
checked={row[key]}
onCheckedChange={(v) => handleToggle(eventType, key, v)}
disabled={disabled}
aria-label={`${eventType} / ${key}`}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
{updateMut.isError && (
@@ -138,43 +185,21 @@ function NotificationPreferencesPage() {
)
}
type ToggleRowProps = {
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
comingSoon?: boolean
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
function ToggleRow({
label,
description,
checked,
onChange,
disabled,
comingSoon,
}: ToggleRowProps) {
const { t } = useTranslation()
return (
<div className="flex items-start justify-between gap-6 border-b border-line py-4 last:border-b-0">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-base font-medium text-ink">{label}</span>
{comingSoon && (
<span className="rounded bg-mute-bg px-2 py-0.5 text-xs text-mute">
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
</span>
)}
</div>
<p className="mt-1 text-sm text-ink-2">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
/>
</div>
)
function humanizeEventType(eventType: NotificationEventType): string {
switch (eventType) {
case 'RecordDraftSubmitted':
return 'Отправлено на ревью'
case 'RecordDraftApproved':
return 'Одобрено'
case 'RecordDraftRejected':
return 'Отклонено'
case 'RecordDraftWithdrawn':
return 'Отозвано'
default:
return eventType
}
}