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:
@@ -721,16 +721,38 @@ export type NotificationsResponse = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase B — per-channel notification opt-in/out для current user. Backend
|
||||
* endpoint: GET/PUT /api/v1/me/notifications/preferences. Default semantics
|
||||
* (server-side, when row missing): email=true, express=false, telegram=false.
|
||||
* Phase B-2 — per-(eventType, channel) notification opt-in/out для current
|
||||
* user. Backend endpoint: GET/PUT /api/v1/me/notifications/preferences.
|
||||
*
|
||||
* <p>Express/Telegram channels пока not wired backend-side — toggles в UI
|
||||
* disabled с tooltip 'Скоро'. Email — единственный фактически работающий
|
||||
* канал в Phase A/B.
|
||||
* <p>Matrix shape: 4 known event types × 3 channels = 12 toggles. User может
|
||||
* opt-out 'Submitted via email' (queue noise) keep 'Approved via email'
|
||||
* (action confirmation).
|
||||
*
|
||||
* <p>Defaults (server-side, для missing rows):
|
||||
* email=true, express=false, telegram=false.
|
||||
*
|
||||
* <p>Resolution для каждой ячейки: specific row → wildcard '*' (Phase B
|
||||
* compat) → default policy.
|
||||
*
|
||||
* <p>Express/Telegram channels не wired backend-side — toggles в UI disabled.
|
||||
*/
|
||||
export type NotificationPreferences = {
|
||||
export type ChannelToggles = {
|
||||
email: boolean
|
||||
express: boolean
|
||||
telegram: boolean
|
||||
}
|
||||
|
||||
/** Map keyed по event type. Backend сейчас возвращает 4 known events. */
|
||||
export type NotificationPreferences = Record<string, ChannelToggles>
|
||||
|
||||
/**
|
||||
* Canonical event types — должны совпадать с backend
|
||||
* MeNotificationsPreferencesController.KNOWN_EVENT_TYPES.
|
||||
*/
|
||||
export const NOTIFICATION_EVENT_TYPES = [
|
||||
'RecordDraftSubmitted',
|
||||
'RecordDraftApproved',
|
||||
'RecordDraftRejected',
|
||||
'RecordDraftWithdrawn',
|
||||
] as const
|
||||
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
||||
|
||||
@@ -635,6 +635,21 @@ i18n
|
||||
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
|
||||
'prefs.comingSoon': 'Скоро',
|
||||
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.',
|
||||
'prefs.descriptionMatrix':
|
||||
'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.',
|
||||
'prefs.matrix.eventCol': 'Событие',
|
||||
'prefs.event.RecordDraftSubmitted.label': 'Отправлено на ревью',
|
||||
'prefs.event.RecordDraftSubmitted.description':
|
||||
'Maker submit-нул draft. Reviewer pool ping о новом элементе в очереди.',
|
||||
'prefs.event.RecordDraftApproved.label': 'Одобрено',
|
||||
'prefs.event.RecordDraftApproved.description':
|
||||
'Reviewer одобрил ваш draft — record live-нут.',
|
||||
'prefs.event.RecordDraftRejected.label': 'Отклонено',
|
||||
'prefs.event.RecordDraftRejected.description':
|
||||
'Reviewer отклонил draft с комментарием — нужны правки.',
|
||||
'prefs.event.RecordDraftWithdrawn.label': 'Отозвано',
|
||||
'prefs.event.RecordDraftWithdrawn.description':
|
||||
'Maker отозвал свой draft до решения. Pool ping чтобы вычистить очередь.',
|
||||
'notifications.preferencesLink': 'Настроить каналы →',
|
||||
'reviews.mine.filter.all': 'Все',
|
||||
'reviews.mine.filter.pending': 'На ревью',
|
||||
@@ -1414,6 +1429,21 @@ i18n
|
||||
'Telegram bot. Channel not wired yet — toggle is stored for later.',
|
||||
'prefs.comingSoon': 'Soon',
|
||||
'prefs.saveError': 'Failed to save preferences. Please try again.',
|
||||
'prefs.descriptionMatrix':
|
||||
'Per-event delivery preferences. Reviewer pool inbox stays ON regardless of your settings.',
|
||||
'prefs.matrix.eventCol': 'Event',
|
||||
'prefs.event.RecordDraftSubmitted.label': 'Submitted for review',
|
||||
'prefs.event.RecordDraftSubmitted.description':
|
||||
'Maker submitted a draft. Reviewer pool ping about new queue item.',
|
||||
'prefs.event.RecordDraftApproved.label': 'Approved',
|
||||
'prefs.event.RecordDraftApproved.description':
|
||||
'Reviewer approved your draft — record went live.',
|
||||
'prefs.event.RecordDraftRejected.label': 'Rejected',
|
||||
'prefs.event.RecordDraftRejected.description':
|
||||
'Reviewer rejected the draft with a comment — needs fixes.',
|
||||
'prefs.event.RecordDraftWithdrawn.label': 'Withdrawn',
|
||||
'prefs.event.RecordDraftWithdrawn.description':
|
||||
'Maker withdrew their draft before decision. Pool ping for queue cleanup.',
|
||||
'notifications.preferencesLink': 'Manage channels →',
|
||||
'reviews.mine.emptyFilter': 'No drafts in this category',
|
||||
'reviews.mine.filter.all': 'All',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user