feat(admin-ui): Phase B frontend — notifications preferences route + UI

This commit is contained in:
Александр Зимин
2026-05-15 14:56:48 +00:00
parent 300fd12d6c
commit e2fc3bcbe0
7 changed files with 332 additions and 4 deletions
+15
View File
@@ -719,3 +719,18 @@ export type NotificationsResponse = {
/** Unread count for badge — pre-filtered server-side. */
totalUnread: number
}
/**
* 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.
*
* <p>Express/Telegram channels пока not wired backend-side — toggles в UI
* disabled с tooltip 'Скоро'. Email — единственный фактически работающий
* канал в Phase A/B.
*/
export type NotificationPreferences = {
email: boolean
express: boolean
telegram: boolean
}
+41
View File
@@ -12,6 +12,7 @@ import {
type DecisionRequest,
type DictionaryDetail,
type DraftResponse,
type NotificationPreferences,
type PublishSchemaDraftRequest,
type PublishSchemaDraftResult,
type RecordResponse,
@@ -700,3 +701,43 @@ export const useMarkAllNotificationsRead = () => {
},
})
}
/**
* Phase B — update per-channel notification preferences. PUT upserts all 3
* каналов атомарно (backend @Transactional). Optimistic update — UI flips
* toggle instantly, onError refetch restores original state.
*
* <p>Cache key 'notifications/me/preferences' — separate from feed query
* (которая использует 'notifications/me'). Invalidate prefs alone не
* triggers feed refetch.
*/
export const useUpdateNotificationPreferences = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (prefs: NotificationPreferences): Promise<NotificationPreferences> => {
const { data } = await apiClient.put<NotificationPreferences>(
'/me/notifications/preferences',
prefs,
)
return data
},
onMutate: async (next) => {
await qc.cancelQueries({ queryKey: ['notifications', 'me', 'preferences'] })
const prev = qc.getQueryData<NotificationPreferences>([
'notifications',
'me',
'preferences',
])
qc.setQueryData(['notifications', 'me', 'preferences'], next)
return { prev }
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
qc.setQueryData(['notifications', 'me', 'preferences'], ctx.prev)
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'preferences'] })
},
})
}
+21
View File
@@ -7,6 +7,7 @@ import {
type CascadePlan,
type ChangelogDiff,
type ChangelogResponse,
type NotificationPreferences,
type NotificationsResponse,
type SchemaDraft,
type SchemaReviewQueuePage,
@@ -851,3 +852,23 @@ export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
useQuery(myNotificationsQuery(unreadOnly, limit))
/**
* Phase B — per-channel notification preferences для current user. GET — single
* row (no pagination), 401 если anonymous. Default semantics filled in
* backend-side когда DB row missing (email=true / express=false / telegram=false).
*/
export const myNotificationPreferencesQuery = () =>
queryOptions({
queryKey: ['notifications', 'me', 'preferences'] as const,
queryFn: async (): Promise<NotificationPreferences> => {
const { data } = await apiClient.get<NotificationPreferences>(
'/me/notifications/preferences',
)
return data
},
staleTime: 60_000, // prefs не меняются часто, кэш дольше
})
export const useMyNotificationPreferences = () =>
useQuery(myNotificationPreferencesQuery())
@@ -88,8 +88,19 @@ export function NotificationsDrawer({ open, onClose }: Props) {
</ul>
)}
{totalUnread > 0 && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<div className="border-t border-line pt-3 flex items-center justify-between">
{/* Phase B: link к per-channel preferences page. Всегда visible
(даже если unread=0 — пользователь может зайти настроить впрок). */}
<Link
to="/me/notifications/preferences"
className="text-sm text-accent hover:underline"
onClick={onClose}
>
{t('notifications.preferencesLink', {
defaultValue: 'Настроить каналы →',
})}
</Link>
{totalUnread > 0 && (
<Button
variant="secondary"
size="sm"
@@ -102,8 +113,8 @@ export function NotificationsDrawer({ open, onClose }: Props) {
defaultValue: 'Прочитать все',
})}
</Button>
</div>
)}
)}
</div>
</div>
</Drawer>
)
+38
View File
@@ -617,6 +617,25 @@ i18n
'reviews.mine.anonTitle': 'Войдите чтобы видеть свои черновики',
'reviews.mine.anonDescription':
'Собственные drafts видны только после авторизации — backend фильтрует по вашему JWT sub claim. Войдите через Keycloak чтобы открыть Мои.',
// === /me/notifications/preferences (Phase B) ===
'prefs.title': 'Уведомления',
'prefs.description':
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).',
'prefs.anonTitle': 'Войдите чтобы управлять подписками',
'prefs.anonDescription':
'Настройки уведомлений хранятся per-user (по JWT sub claim). Войдите через Keycloak чтобы открыть страницу.',
'prefs.email.label': 'Email',
'prefs.email.description':
'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.',
'prefs.express.label': 'Express',
'prefs.express.description':
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
'prefs.telegram.label': 'Telegram',
'prefs.telegram.description':
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
'prefs.comingSoon': 'Скоро',
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.',
'notifications.preferencesLink': 'Настроить каналы →',
'reviews.mine.filter.all': 'Все',
'reviews.mine.filter.pending': 'На ревью',
'reviews.mine.filter.wip': 'В работе',
@@ -1377,6 +1396,25 @@ i18n
'reviews.mine.anonTitle': 'Sign in to see your drafts',
'reviews.mine.anonDescription':
'Your own drafts are visible only after sign-in — backend filters by your JWT sub claim. Sign in via Keycloak to open Mine.',
// === /me/notifications/preferences (Phase B) ===
'prefs.title': 'Notifications',
'prefs.description':
'Per-channel delivery preferences. Applies only to your notifications (reviewer pool inbox stays ON).',
'prefs.anonTitle': 'Sign in to manage your subscriptions',
'prefs.anonDescription':
'Notification preferences are stored per-user (by JWT sub claim). Sign in via Keycloak to open this page.',
'prefs.email.label': 'Email',
'prefs.email.description':
'SMTP delivery via configured mail.host. On staging it is the mailpit catcher; on prod — real relay.',
'prefs.express.label': 'Express',
'prefs.express.description':
'Corporate messenger. Channel not wired yet — toggle is stored for later.',
'prefs.telegram.label': 'Telegram',
'prefs.telegram.description':
'Telegram bot. Channel not wired yet — toggle is stored for later.',
'prefs.comingSoon': 'Soon',
'prefs.saveError': 'Failed to save preferences. Please try again.',
'notifications.preferencesLink': 'Manage channels →',
'reviews.mine.emptyFilter': 'No drafts in this category',
'reviews.mine.filter.all': 'All',
'reviews.mine.filter.pending': 'Under review',
+22
View File
@@ -22,6 +22,7 @@ import { Route as WebhooksIndexRouteImport } from './routes/webhooks.index'
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id'
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
import { Route as MeNotificationsPreferencesRouteImport } from './routes/me.notifications.preferences'
const WebhooksRoute = WebhooksRouteImport.update({
id: '/webhooks',
@@ -88,6 +89,12 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
path: '/$name',
getParentRoute: () => DictionariesRoute,
} as any)
const MeNotificationsPreferencesRoute =
MeNotificationsPreferencesRouteImport.update({
id: '/me/notifications/preferences',
path: '/me/notifications/preferences',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -103,6 +110,7 @@ export interface FileRoutesByFullPath {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -116,6 +124,7 @@ export interface FileRoutesByTo {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries': typeof DictionariesIndexRoute
'/webhooks': typeof WebhooksIndexRoute
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -132,6 +141,7 @@ export interface FileRoutesById {
'/webhooks/$id': typeof WebhooksIdRoute
'/dictionaries/': typeof DictionariesIndexRoute
'/webhooks/': typeof WebhooksIndexRoute
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries/'
| '/webhooks/'
| '/me/notifications/preferences'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -162,6 +173,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries'
| '/webhooks'
| '/me/notifications/preferences'
id:
| '__root__'
| '/'
@@ -177,6 +189,7 @@ export interface FileRouteTypes {
| '/webhooks/$id'
| '/dictionaries/'
| '/webhooks/'
| '/me/notifications/preferences'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -189,6 +202,7 @@ export interface RootRouteChildren {
ReviewsRoute: typeof ReviewsRoute
SearchRoute: typeof SearchRoute
WebhooksRoute: typeof WebhooksRouteWithChildren
MeNotificationsPreferencesRoute: typeof MeNotificationsPreferencesRoute
}
declare module '@tanstack/react-router' {
@@ -284,6 +298,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DictionariesNameRouteImport
parentRoute: typeof DictionariesRoute
}
'/me/notifications/preferences': {
id: '/me/notifications/preferences'
path: '/me/notifications/preferences'
fullPath: '/me/notifications/preferences'
preLoaderRoute: typeof MeNotificationsPreferencesRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -325,6 +346,7 @@ const rootRouteChildren: RootRouteChildren = {
ReviewsRoute: ReviewsRoute,
SearchRoute: SearchRoute,
WebhooksRoute: WebhooksRouteWithChildren,
MeNotificationsPreferencesRoute: MeNotificationsPreferencesRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -0,0 +1,180 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from 'react-oidc-context'
import {
Button,
EmptyState,
LoadingBlock,
PageHeader,
Panel,
QueryErrorState,
Switch,
} from '@/ui'
import { useMyNotificationPreferences } from '@/api/queries'
import { useUpdateNotificationPreferences } from '@/api/mutations'
import type { NotificationPreferences } from '@/api/client'
export const Route = createFileRoute('/me/notifications/preferences')({
component: NotificationPreferencesPage,
})
/**
* Phase B — UI для per-channel notification opt-in/out.
*
* <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>Optimistic update — toggle flips мгновенно, network roundtrip ~200ms
* почти не виден. onError refetch восстанавливает state если PUT 4xx/5xx.
*/
function NotificationPreferencesPage() {
const { t } = useTranslation()
const auth = useAuth()
const prefsQ = useMyNotificationPreferences()
const updateMut = useUpdateNotificationPreferences()
if (!auth.isAuthenticated) {
return (
<Panel>
<PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям.',
})}
/>
<EmptyState
title={t('prefs.anonTitle', {
defaultValue: 'Войдите чтобы управлять подписками',
})}
description={t('prefs.anonDescription', {
defaultValue:
'Настройки уведомлений хранятся per-user (по JWT sub claim). Войдите через Keycloak чтобы открыть страницу.',
})}
action={
<Button onClick={() => auth.signinRedirect()}>
{t('auth.login', { defaultValue: 'Войти' })}
</Button>
}
/>
</Panel>
)
}
if (prefsQ.isLoading) {
return <LoadingBlock size="md" label={t('loading')} />
}
if (prefsQ.error) {
return <QueryErrorState error={prefsQ.error} onRetry={() => prefsQ.refetch()} />
}
const prefs: NotificationPreferences = prefsQ.data ?? {
email: true,
express: false,
telegram: false,
}
const handleToggle = (channel: keyof NotificationPreferences, next: boolean) => {
updateMut.mutate({ ...prefs, [channel]: next })
}
return (
<Panel>
<PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (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>
{updateMut.isError && (
<div
role="alert"
className="mt-4 rounded border border-danger bg-danger-bg p-3 text-sm text-danger"
>
{t('prefs.saveError', {
defaultValue: 'Не удалось сохранить настройки. Попробуйте ещё раз.',
})}
</div>
)}
</Panel>
)
}
type ToggleRowProps = {
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
comingSoon?: boolean
}
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>
)
}