From e2fc3bcbe0b398c15fca20b5603db1152fb2089c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Fri, 15 May 2026 14:56:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20Phase=20B=20frontend=20?= =?UTF-8?q?=E2=80=94=20notifications=20preferences=20route=20+=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/client.ts | 15 ++ ordinis-admin-ui/src/api/mutations.ts | 41 ++++ ordinis-admin-ui/src/api/queries.ts | 21 ++ .../notifications/NotificationsDrawer.tsx | 19 +- ordinis-admin-ui/src/i18n.ts | 38 ++++ ordinis-admin-ui/src/routeTree.gen.ts | 22 +++ .../routes/me.notifications.preferences.tsx | 180 ++++++++++++++++++ 7 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 ordinis-admin-ui/src/routes/me.notifications.preferences.tsx diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index c7e1825..97df656 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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. + * + *

Express/Telegram channels пока not wired backend-side — toggles в UI + * disabled с tooltip 'Скоро'. Email — единственный фактически работающий + * канал в Phase A/B. + */ +export type NotificationPreferences = { + email: boolean + express: boolean + telegram: boolean +} diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index e988f3c..d81d37e 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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. + * + *

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 => { + const { data } = await apiClient.put( + '/me/notifications/preferences', + prefs, + ) + return data + }, + onMutate: async (next) => { + await qc.cancelQueries({ queryKey: ['notifications', 'me', 'preferences'] }) + const prev = qc.getQueryData([ + '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'] }) + }, + }) +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index c83d187..29ecb77 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + const { data } = await apiClient.get( + '/me/notifications/preferences', + ) + return data + }, + staleTime: 60_000, // prefs не меняются часто, кэш дольше + }) + +export const useMyNotificationPreferences = () => + useQuery(myNotificationPreferencesQuery()) diff --git a/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx b/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx index 94b034c..03bb946 100644 --- a/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx +++ b/ordinis-admin-ui/src/components/notifications/NotificationsDrawer.tsx @@ -88,8 +88,19 @@ export function NotificationsDrawer({ open, onClose }: Props) { )} - {totalUnread > 0 && ( -

+
+ {/* Phase B: link к per-channel preferences page. Всегда visible + (даже если unread=0 — пользователь может зайти настроить впрок). */} + + {t('notifications.preferencesLink', { + defaultValue: 'Настроить каналы →', + })} + + {totalUnread > 0 && (
- )} + )} +
) diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index d9a2b30..f74a613 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routeTree.gen.ts b/ordinis-admin-ui/src/routeTree.gen.ts index 8e6c708..ffc69bd 100644 --- a/ordinis-admin-ui/src/routeTree.gen.ts +++ b/ordinis-admin-ui/src/routeTree.gen.ts @@ -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) diff --git a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx new file mode 100644 index 0000000..b674b60 --- /dev/null +++ b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx @@ -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. + * + *

Email — единственный actually wired channel в Phase A/B (через Spring + * \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой + * "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher + * никогда не пошлёт пока channel bean не зарегистрирован. + * + *

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 ( + + + auth.signinRedirect()}> + {t('auth.login', { defaultValue: 'Войти' })} + + } + /> + + ) + } + + if (prefsQ.isLoading) { + return + } + if (prefsQ.error) { + return 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 ( + + + +

+ handleToggle('email', v)} + /> + handleToggle('express', v)} + disabled + comingSoon + /> + handleToggle('telegram', v)} + disabled + comingSoon + /> +
+ + {updateMut.isError && ( +
+ {t('prefs.saveError', { + defaultValue: 'Не удалось сохранить настройки. Попробуйте ещё раз.', + })} +
+ )} + + ) +} + +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 ( +
+
+
+ {label} + {comingSoon && ( + + {t('prefs.comingSoon', { defaultValue: 'Скоро' })} + + )} +
+

{description}

+
+ +
+ ) +}