feat(notifications): TODO 7 — draft decision toast + email + bell badge

This commit is contained in:
Александр Зимин
2026-05-14 14:40:35 +00:00
parent 50d263745a
commit 9050e2427e
21 changed files with 1992 additions and 82 deletions
+32
View File
@@ -687,3 +687,35 @@ export type ChangelogDiff = {
after: unknown | null
summary: ChangelogDiffSummary
}
// === Notifications (TODO 7: draft decision toast + email) ===
/**
* Single notification row для current user. Backend serializes
* {@code notification_log} entry joined с {@code notification_read_state}
* (per-user unread tracking).
*
* <p>{@code payload} — JSON payload эвента (e.g. RecordDraftApproved).
* {@code payloadPreview} — server-pre-rendered summary string чтобы UI
* не парсил event payload (event_type → user-facing text mapping живёт
* backend'е).
*/
export type NotificationItem = {
id: string
eventType: string
channel: 'EMAIL' | 'EXPRESS' | 'TELEGRAM' | 'IN_APP'
sentAt: string
read: boolean
payloadPreview: string
/** Optional deep-link (e.g. /reviews/{draftId} для draft decision). */
href?: string | null
}
export type NotificationsResponse = {
items: NotificationItem[]
page: number
size: number
totalElements: number
/** Unread count for badge — pre-filtered server-side. */
totalUnread: number
}
+36
View File
@@ -664,3 +664,39 @@ export const useRetryWebhookDelivery = () => {
},
})
}
// === Notifications mutations (TODO 7) ===
/**
* Mark одно notification как read. Optimistic update — UI обновляется
* мгновенно, query invalidates после server confirm. Если backend reject'нет,
* onError refetch'нет original state.
*/
export const useMarkNotificationRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (notificationId: string): Promise<void> => {
await apiClient.post(
`/me/notifications/${encodeURIComponent(notificationId)}/read`,
)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
/**
* Mark все unread notifications as read. Drawer footer button "Mark all read".
*/
export const useMarkAllNotificationsRead = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await apiClient.post('/me/notifications/read-all')
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
},
})
}
+34
View File
@@ -7,6 +7,7 @@ import {
type CascadePlan,
type ChangelogDiff,
type ChangelogResponse,
type NotificationsResponse,
type SchemaDraft,
type SchemaReviewQueuePage,
type DictionaryDefinition,
@@ -804,3 +805,36 @@ export const serverVersionQuery = queryOptions({
})
export const useServerVersion = () => useQuery(serverVersionQuery)
// === Notifications (TODO 7: draft decision toast + email) ===
/**
* Current user's notifications feed. Backend filters by recipient = sub claim;
* returns paginated rows + `totalUnread` для bell badge counter.
*
* <p>Poll каждые 30s — same cadence как /reviews queue. {@code staleTime} 5s
* чтобы query cache не моргал когда user открывает/закрывает drawer.
* {@code refetchIntervalInBackground} = true чтобы tab без focus всё равно
* decremented badge когда mark-as-read fires.
*
* <p>Caveat: `unreadOnly=true` gives a tight badge query (1 unread → badge "1"),
* full feed query (default) used drawer открыт. Splitting queries keeps badge
* fetch cheap when drawer закрыт.
*/
export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
queryOptions({
queryKey: ['notifications', 'me', { unreadOnly, limit }] as const,
queryFn: async (): Promise<NotificationsResponse> => {
const { data } = await apiClient.get<NotificationsResponse>(
'/me/notifications',
{ params: { unreadOnly, limit } },
)
return data
},
refetchInterval: 30_000,
refetchIntervalInBackground: true,
staleTime: 5_000,
})
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
useQuery(myNotificationsQuery(unreadOnly, limit))
@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from '@tanstack/react-router'
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
import { useDictionaries, useDictionaryDetail, useMyNotifications } from '@/api/queries'
import { VersionBadge } from '@/components/version/VersionBadge'
import { WhatsNewButton } from '@/components/version/WhatsNewButton'
import { NotificationsDrawer } from '@/components/notifications/NotificationsDrawer'
import { toast } from '@/ui'
import { Bell, Menu } from 'lucide-react'
/**
@@ -101,25 +104,72 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
}
/**
* Placeholder notifications bell — будет показывать N unread сообщений
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
* Сейчас static bell без counter — wire'ится когда backend notifications
* stream появится.
* Notifications bell с unread counter + drawer. TODO 7 wiring:
* <ul>
* <li>{@link useMyNotifications} с {@code unreadOnly=true} polls /me/notifications
* каждые 30s (cheap query — фильтр backend-side).</li>
* <li>Counter badge показывается если {@code totalUnread > 0}. Capped на "9+"
* чтобы layout не плыл при бурном reviewer flow.</li>
* <li>Click → opens {@link NotificationsDrawer} с full feed (unreadOnly=false).</li>
* <li>Delta-detection: если {@code totalUnread} вырос между poll'ами и drawer
* закрыт — fires `toast.info` с preview top-most new item. Reviewer'у
* не нужно держать tab focused чтобы заметить approve.</li>
* </ul>
*
* <p>«Что нового» (WhatsNewButton) — adjacent button показывает release
* notes, отделён от operational notifications (разные mental models).
*/
function NotificationsBell() {
const { t } = useTranslation()
const [drawerOpen, setDrawerOpen] = useState(false)
// Light query: только totalUnread + top item, used to drive badge + toast.
const q = useMyNotifications(true, 5)
const totalUnread = q.data?.totalUnread ?? 0
// Delta toast: detect новые notifications между polls. Hold prev count
// в ref чтобы не trigger'ить toast на первый mount (initial load).
const prevUnreadRef = useRef<number | null>(null)
useEffect(() => {
const prev = prevUnreadRef.current
if (prev != null && totalUnread > prev && !drawerOpen) {
const top = q.data?.items[0]
toast(
top?.payloadPreview ??
t('notifications.newDefault', { defaultValue: 'Новое уведомление' }),
{
description: t('notifications.toastDescription', {
defaultValue: 'Откройте колокольчик чтобы увидеть подробности.',
}),
},
)
}
prevUnreadRef.current = totalUnread
}, [totalUnread, drawerOpen, q.data, t])
return (
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
</button>
<>
<button
type="button"
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
onClick={() => setDrawerOpen(true)}
className="relative size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
>
<Bell size={14} strokeWidth={1.75} />
{totalUnread > 0 && (
<span
aria-label={t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})}
className="absolute -top-1 -right-1 min-w-[1rem] h-[1rem] px-1 rounded-full bg-pink text-on-accent text-[0.625rem] leading-none font-medium tabular-nums inline-flex items-center justify-center"
>
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</button>
<NotificationsDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
)
}
@@ -0,0 +1,225 @@
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import {
Badge,
Button,
Drawer,
EmptyState,
LoadingBlock,
QueryErrorState,
} from '@/ui'
import { BellIcon, CheckIcon } from '@phosphor-icons/react'
import { useMyNotifications } from '@/api/queries'
import {
useMarkAllNotificationsRead,
useMarkNotificationRead,
} from '@/api/mutations'
import type { NotificationItem } from '@/api/client'
type Props = {
open: boolean
onClose: () => void
}
/**
* Right-side drawer показывает recent notifications для current user.
* Rendered поверх TopBar bell click. Polled через {@link useMyNotifications}
* (30s interval same как /reviews queue).
*
* <p>Каждая row показывает: channel icon → preview text → relative time →
* unread dot. Click → either deep-link (e.g. /reviews/{draftId}) ИЛИ просто
* mark-as-read inline. Backend providing `href` для approval-related events
* (draft decision toast spec из TODO 7).
*
* <p>Footer: "Mark all as read" button когда есть unread; иначе hidden.
*/
export function NotificationsDrawer({ open, onClose }: Props) {
const { t } = useTranslation()
const q = useMyNotifications(false, 20)
const markOne = useMarkNotificationRead()
const markAll = useMarkAllNotificationsRead()
const items = q.data?.items ?? []
const totalUnread = q.data?.totalUnread ?? 0
return (
<Drawer
isOpen={open}
onClose={onClose}
side="right"
widthClassName="w-full max-w-md"
title={t('notifications.title', { defaultValue: 'Уведомления' })}
description={
totalUnread > 0
? t('notifications.unreadCount', {
count: totalUnread,
defaultValue: `${totalUnread} непрочитанных`,
})
: t('notifications.allRead', { defaultValue: 'Всё прочитано' })
}
>
<div className="space-y-3">
{q.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{q.error && <QueryErrorState error={q.error} onRetry={() => q.refetch()} />}
{!q.isLoading && !q.error && items.length === 0 && (
<EmptyState
icon={<BellIcon weight="duotone" size={28} />}
title={t('notifications.empty', {
defaultValue: 'Уведомлений пока нет',
})}
description={t('notifications.emptyDescription', {
defaultValue:
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
})}
/>
)}
{items.length > 0 && (
<ul className="divide-y divide-line">
{items.map((n) => (
<NotificationRow
key={n.id}
item={n}
onMarkRead={() => markOne.mutate(n.id)}
onClose={onClose}
/>
))}
</ul>
)}
{totalUnread > 0 && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<Button
variant="secondary"
size="sm"
leftIcon={<CheckIcon weight="bold" size={14} />}
onClick={() => markAll.mutate()}
loading={markAll.isPending}
disabled={markAll.isPending}
>
{t('notifications.markAllRead', {
defaultValue: 'Прочитать все',
})}
</Button>
</div>
)}
</div>
</Drawer>
)
}
function NotificationRow({
item,
onMarkRead,
onClose,
}: {
item: NotificationItem
onMarkRead: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const content = (
<div className="flex items-start gap-3 py-3 group">
{/* Unread dot — visual cue для еще-не-прочитанных. Скрыт когда read.
aria-label чтобы screen reader озвучил статус. */}
<span
aria-label={
item.read
? t('notifications.read', { defaultValue: 'Прочитано' })
: t('notifications.unread', { defaultValue: 'Непрочитано' })
}
className={
'mt-1.5 size-2 shrink-0 rounded-full ' +
(item.read ? 'bg-transparent' : 'bg-accent')
}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={badgeVariant(item.eventType)}>
{humanEventType(item.eventType)}
</Badge>
<Badge variant="outline" className="lowercase">
{item.channel}
</Badge>
</div>
<p className={'text-cell ' + (item.read ? 'text-ink-2' : 'text-ink font-medium')}>
{item.payloadPreview}
</p>
<p className="text-cap text-mute tabular-nums">
{new Date(item.sentAt).toLocaleString()}
</p>
</div>
{!item.read && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMarkRead()
}}
aria-label={t('notifications.markRead', {
defaultValue: 'Отметить прочитанным',
})}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity text-mute hover:text-ink shrink-0"
>
<CheckIcon weight="bold" size={14} />
</button>
)}
</div>
)
// Deep-link case: row is a Link, click closes drawer + navigates +
// implicitly marks-as-read через server (backend should mark read on
// resource load OR we add explicit POST here).
if (item.href) {
return (
<li>
<Link
to={item.href}
onClick={() => {
if (!item.read) onMarkRead()
onClose()
}}
className="block hover:bg-line/30 -mx-3 px-3 rounded-sm transition-colors"
>
{content}
</Link>
</li>
)
}
return <li>{content}</li>
}
/**
* Event type → Badge variant mapping. Decision-positive (approved/published)
* → success, negative (rejected/withdrawn) → danger, neutral progress
* (submitted/changes_requested) → info.
*/
function badgeVariant(
eventType: string,
): 'success' | 'danger' | 'info' | 'warning' | 'default' {
if (/Approved|Published/i.test(eventType)) return 'success'
if (/Rejected|Withdrawn/i.test(eventType)) return 'danger'
if (/Submitted|ChangesRequested/i.test(eventType)) return 'info'
return 'default'
}
/**
* Event type → short russian label для chip. Server-side resolution лучше,
* но пока mapping inline здесь — backend payloadPreview уже несёт полный
* человеческий текст, chip это просто категория.
*/
function humanEventType(eventType: string): string {
const map: Record<string, string> = {
RecordDraftSubmitted: 'submit',
RecordDraftApproved: 'approved',
RecordDraftRejected: 'rejected',
RecordDraftWithdrawn: 'withdrawn',
SchemaDraftSubmitted: 'schema submit',
SchemaDraftApproved: 'schema approved',
SchemaDraftRejected: 'schema rejected',
SchemaDraftPublished: 'schema published',
}
return map[eventType] ?? eventType
}
+30
View File
@@ -581,6 +581,22 @@ i18n
'reviews.tab.records': 'Записи',
'reviews.tab.schemas': 'Схемы',
'reviews.tab.mine': 'Мои',
// === Notifications (TODO 7) ===
'notifications.title': 'Уведомления',
'notifications.empty': 'Уведомлений пока нет',
'notifications.emptyDescription':
'Здесь будут уведомления о решениях по вашим черновикам и других событиях.',
'notifications.markAllRead': 'Прочитать все',
'notifications.markRead': 'Отметить прочитанным',
'notifications.read': 'Прочитано',
'notifications.unread': 'Непрочитано',
'notifications.allRead': 'Всё прочитано',
'notifications.unreadCount_one': '{{count}} непрочитанное',
'notifications.unreadCount_few': '{{count}} непрочитанных',
'notifications.unreadCount_many': '{{count}} непрочитанных',
'notifications.newDefault': 'Новое уведомление',
'notifications.toastDescription':
'Откройте колокольчик чтобы увидеть подробности.',
// Interaction state polish (TODO 2)
'reviews.emptyDescription':
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
@@ -1323,6 +1339,20 @@ i18n
'reviews.tab.records': 'Records',
'reviews.tab.schemas': 'Schemas',
'reviews.tab.mine': 'Mine',
// === Notifications (TODO 7) ===
'notifications.title': 'Notifications',
'notifications.empty': 'No notifications yet',
'notifications.emptyDescription':
'Decisions on your drafts and other events will appear here.',
'notifications.markAllRead': 'Mark all read',
'notifications.markRead': 'Mark read',
'notifications.read': 'Read',
'notifications.unread': 'Unread',
'notifications.allRead': 'All read',
'notifications.unreadCount_one': '{{count}} unread',
'notifications.unreadCount_other': '{{count}} unread',
'notifications.newDefault': 'New notification',
'notifications.toastDescription': 'Open the bell to see details.',
// Interaction state polish (TODO 2)
'reviews.emptyDescription':
'Queue is clear. Past decisions live in the audit log.',