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))