feat(admin-ui): Phase B frontend — notifications preferences route + UI
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user